自定义组件之<八>自定义下拉刷新(RefreshList)
9.8:自定义下拉刷新(RefreshList)
下拉刷新是一个很常用的功能,绝大多数的 APP 都有该功能,比如新闻类 APP 使用下拉刷新更新最新新闻资讯等场景,本节笔者简单实现一个基于 List
的自定义下拉刷新组件 RefreshList,该组件的运行效果如下图所示:
9.8.1:RefreshList布局拆分
下拉刷新组件都是分为上下两部分,上边是刷新头:refreshHead,该刷新头根据手指的下滑距离提示是否达到刷新条件;下边是刷新体:refreshContent,当触发下拉刷新条件后对外回调,从而实现内容更新。笔者实现的 RefreshList 也是按照以上布局实现的,简化图如下所示:
默认情况下 refreshHead 是布局在 RefreshList 可视区域外边,可以使用 position()
方法实现布局定位,简化代码如下所示:
@Component struct RefreshList {
build() {
Column() {
Row() {
// header布局
}
.id("refresh_header")
.width("100%")
.height(50)
.position({ // 利用该属性,把refresh_header布局在 Column 顶部
x: 0,
y: -50
})
Column() {
// content 布局
}
.id("refresh_content")
.width("100%")
.height("100%")
.position({ // 利用该属性,把refresh_content布局向上做偏移
x: 0,
y: 0
})
}
.id("refresh_list")
.width("100%")
.height("100%")
}
}
9.8.2:RefreshList滑动处理
ArkUI 开发框架对于手势事件的处理遵循 W3C 标准,首先是目标捕获阶段,然后再是事件冒泡阶段,下拉刷新的操作就是在事件冒泡阶段处理的,因此直接实现 refresh_list 的 onTouch()
方法即可,在该方法内根据手指的滑动距离动态实现 refreshHeader 和 refreshContent 的布局定位即可,简化代码如下所示:
@Component struct RefreshList {
private refreshHeaderHeight: number = 50;
private offsetY: number = -this.refreshHeaderHeight;
private lastX: number;
private lastY: number;
private downY: number;
build() {
Column() {
Row()
.id("refresh_header")
.width("100%")
.height(this.refreshHeaderHeight)
.backgroundColor("#bbaacc")
.position({
x: 0,
y: this.offsetY
})
Column() {
}
.id("refresh_content")
.width("100%")
.height("100%")
.backgroundColor("#aabbcc")
.position({
x: 0,
y: this.offsetY + this.refreshHeaderHeight
})
}
.id("refresh_list")
.width("100%")
.height("100%")
.onTouch((event) => {
if (event.type == TouchType.Down) {
// 处理 down 事件
this.onTouchDown(event);
} else if (event.type == TouchType.Move) {
// 处理 move 事件
this.onTouchMove(event);
} else if (event.type == TouchType.Cancel || event.type == TouchType.Up) {
// 处理 up 事件
this.onTouchUp(event);
}
})
}
private onTouchDown(event: TouchEvent) {
this.lastX = event.touches[0].screenX;
this.lastY = event.touches[0].screenY;
this.downY = this.lastY;
}
private onTouchMove(event: TouchEvent) {
let currentX = event.touches[0].screenX;
let currentY = event.touches[0].screenY;
let deltaX = currentX - this.lastX;
let deltaY = currentY - this.lastY;
if (Math.abs(deltaX) < Math.abs(deltaY) && Math.abs(deltaY) > 5) {
// 达到滑动条件
}
}
private onTouchUp(event: TouchEvent) {
}
}
9.8.3:RefreshList滑动冲突
由于 refreshContent 内部包含的是 List 组件,该组件比较特殊,它会默认响应手势的滑动操作,在处理外层滑动的时候该 List 也会跟着一起滑动,这种体验是非常不友好的,因此可以在 List 的 onScrollBegin()
方法中处理滑动冲突,简化代码如下所示:
@Component struct RefreshList {
build() {
Column() {
Row()
.id("refresh_header")
.position({
x: 0,
y: this.offsetY
})
Column() {
List({scroller: this.listScroller}) {
}
.edgeEffect(EdgeEffect.None)
.onScrollBegin((dx: number, dy: number) => { // 处理滑动冲突
dy = this.listScrollable ? dy : 0;
return {dxRemain: dx, dyRemain: dy}
})
}
.id("refresh_content")
.position({
x: 0,
y: this.offsetY + this.refreshHeaderHeight
})
}
.id("refresh_list")
}
}
listScrollable 属性表示 List 是否可以滚动,当在处理外部滑动的时候禁止内部的 List 滑动,此时 让 onScrollBegin()
方法返回的 dyRemain 为 0 即可。
9.8.4:RefreshList完整代码
动态布局完 refreshHeader
和 refreshContent
后,在 refresh_list 的 onTouch()
方法内处理滑动操作,一个下拉刷新的轮廓基本就形成了,笔者实现的 RefreshList 完整代码如下所示:
export class Constant {
static readonly REFRESH_PULL_TO_REFRESH = "下拉刷新";
static readonly REFRESH_FREE_TO_REFRESH = "释放立即刷新";
static readonly REFRESH_REFRESHING = "正在刷新";
static readonly REFRESH_SUCCESS = "刷新成功";
}
@ComponentV2
export struct RefreshList {
@BuilderParam
itemLayout?: (item: Object, index: number) => void;
@Require @Param refreshing: boolean;
@Event $refreshing: (refreshing: boolean) => void = (refreshing: boolean) => {}
@Require @Param dataSet: Array<Object>;
@Param onRefresh: () => void = () => {}
@Param onStatusChanged: (status: RefreshStatus) => void = (status: RefreshStatus) => {}
private headHeight: number = 55;
private lastX: number = 0;
private lastY: number = 0;
private downY: number = 0;
private flingFactor: number = 0.75;
private touchSlop: number = 2;
private offsetStep: number = 10;
private intervalTime: number = 20;
private listScrollable: boolean = true;
private dragging: boolean = false;
private refreshStatus: RefreshStatus = RefreshStatus.Inactive;
@Local offsetY: number = -this.headHeight;
@Local refreshHeadIcon: Resource = $r("app.media.icon_refresh_down");
@Local refreshHeadText: string = Constant.REFRESH_PULL_TO_REFRESH;
@Local refreshContentH: number = 0;
@Local touchEnabled: boolean = true;
@Local headerVisibility: Visibility = Visibility.None;
private listScroller: Scroller = new Scroller();
@Monitor("refreshing")
notifyRefreshingChanged(monitor: IMonitor) {
console.log("notifyRefreshingChanged: " + this.refreshing)
if (this.refreshing) {
this.showRefreshingStatus();
} else {
this.finishRefresh();
}
}
@Monitor("offsetY")
notifyOffsetYChanged(monitor: IMonitor) {
this.headerVisibility = (this.offsetY == -this.headHeight) ? Visibility.None : Visibility.Visible;
}
@Builder headLayout() {
Row() {
Blank()
Image(this.refreshHeadIcon)
.width(30)
.aspectRatio(1)
.objectFit(ImageFit.Contain)
Text(this.refreshHeadText)
.fontSize(16)
.width(150)
.textAlign(TextAlign.Center)
Blank()
}
.width("100%")
.height(this.headHeight)
.backgroundColor("#44bbccaa")
.visibility(this.headerVisibility)
.position({
x: 0,
y: this.offsetY
})
}
build() {
Column() {
this.headLayout()
Column() {
List({scroller: this.listScroller}) {
if (this.dataSet) {
ForEach(this.dataSet, (item: Object, index: number) => {
ListItem() {
if (this.itemLayout) {
this.itemLayout(item, index)
}
}
.width("100%")
}, (item: Object, index: number) => item.toString())
}
}
.width("100%")
.height("100%")
.edgeEffect(EdgeEffect.None)
// .onScrollBegin((dx: number, dy: number) => {
// dy = this.listScrollable ? dy : 0;
// return {dxRemain: dx, dyRemain: dy}
// })
.onScrollFrameBegin((offset: number, state: ScrollState) => {
offset = this.listScrollable ? offset : 0;
return {offsetRemain: offset}
})
}
.width("100%")
.layoutWeight(1)
.backgroundColor(Color.Pink)
.position({
x: 0,
y: this.offsetY + this.headHeight
})
}
.width("100%")
.height("100%")
.enabled(this.touchEnabled)
.onAreaChange((oldArea, newAre) => {
console.log("Refresh height: " + newAre.height);
this.refreshContentH = Number(newAre.height);
})
.clip(true)
.onTouch((event) => {
if (event.touches.length != 1) {
this.logD("TOUCHES LENGTH INVALID: " + JSON.stringify(event.touches))
event.stopPropagation();
return
}
switch (event.type) {
case TouchType.Down:
this.onTouchDown(event);
break;
case TouchType.Move:
this.onTouchMove(event);
break;
case TouchType.Up:
case TouchType.Cancel:
this.onTouchUp(event);
break;
}
event.stopPropagation();
})
}
private setRefreshStatus(status: RefreshStatus) {
this.refreshStatus = status;
let refreshing = (status == RefreshStatus.Refresh);
this.$refreshing(refreshing)
this.touchEnabled = (status != RefreshStatus.Refresh && status != RefreshStatus.Done);
this.notifyStatusChanged();
}
private canRefresh() {
return this.listScroller.currentOffset().yOffset == 0;
}
private onTouchDown(event: TouchEvent) {
this.lastX = event.touches[0].screenX;
this.lastY = event.touches[0].screenY;
this.downY = this.lastY;
this.dragging = false;
this.listScrollable = true;
this.logD("Touch DOWN: " + event.touches[0].screenX.toFixed(2) + " x " + event.touches[0].screenY.toFixed(2) + ", offset: " + this.offsetY);
}
private onTouchMove(event: TouchEvent) {
let currentX = event.touches[0].screenX;
let currentY = event.touches[0].screenY;
let deltaX = currentX - this.lastX;
let deltaY = currentY - this.lastY;
if (this.dragging) {
this.logD("offsetY: " + this.offsetY.toFixed(2) + ", head: " + (-this.headHeight));
if (deltaY < 0) {
// 手势向上拖动
if (this.offsetY > -this.headHeight) {
this.logD("手指向上拖动还未到达临界值,不让 list 滚动")
this.offsetY = this.offsetY + px2vp(deltaY) * this.flingFactor;
this.listScrollable = false;
} else {
this.logD("手指向上拖动到达临界值了,开始让 list 滚动")
this.offsetY = -this.headHeight;
this.listScrollable = true;
this.downY = this.lastY;
}
} else {
// 手势向下拖动
this.logD("手指向下拖动中")
if (this.canRefresh()) {
this.offsetY = this.offsetY + px2vp(deltaY) * this.flingFactor;
this.listScrollable = false;
} else {
this.listScrollable = true;
}
}
this.lastX = currentX;
this.lastY = currentY;
} else {
if (Math.abs(deltaX) < Math.abs(deltaY) && Math.abs(deltaY) > this.touchSlop) {
if (deltaY > 0 && this.canRefresh()) {
this.dragging = true;
this.listScrollable = false
this.lastX = currentX;
this.lastY = currentY;
this.logD("Touch MOVE: 手指向下滑动,达到了拖动条件")
}
}
}
if(this.dragging) {
if (currentY >= this.downY) {
if (this.offsetY >= 0 || (this.headHeight - Math.abs(this.offsetY)) > this.headHeight * 4 / 5) {
this.refreshHeadText = Constant.REFRESH_FREE_TO_REFRESH;
this.refreshHeadIcon = $r("app.media.icon_refresh_up");
this.setRefreshStatus(RefreshStatus.OverDrag);
} else {
this.refreshHeadText = Constant.REFRESH_PULL_TO_REFRESH;
this.refreshHeadIcon = $r("app.media.icon_refresh_down");
this.setRefreshStatus(RefreshStatus.Drag);
}
}
}
// this.logD("Touch MOVE: " + event.touches[0].screenX + " x " + event.touches[0].screenY + ", offset: " + this.offsetY);
}
private onTouchUp(event: TouchEvent) {
this.logD("Touch UP: " + event.touches[0].screenX.toFixed(2) + " x " + event.touches[0].screenY.toFixed(2) + ", offset: " + this.offsetY);
if (this.dragging) {
// 手指最终向下滑动
if (this.offsetY >= 0 || (this.headHeight - Math.abs(this.offsetY)) > this.headHeight * 4 / 5) {
this.logD("Touch UP: 最终达到下拉刷新条件")
this.refreshHeadIcon = $r("app.media.icon_refresh_loading");
this.refreshHeadText = Constant.REFRESH_REFRESHING;
this.setRefreshStatus(RefreshStatus.Refresh);
this.scrollToTop();
this.notifyRefreshStart();
} else {
this.logD("Touch UP: 最终未达到下拉刷新条件")
this.refreshHeadIcon = $r("app.media.icon_refresh_down");
this.refreshHeadText = Constant.REFRESH_PULL_TO_REFRESH;
this.setRefreshStatus(RefreshStatus.Drag);
this.scrollByTop();
}
}
}
private scrollToTop() {
this.offsetY = 0;
}
private scrollByTop() {
if (this.offsetY != -this.headHeight) {
this.logD("scrollByTop() start, offsetY: " + this.offsetY.toFixed(2));
let intervalId = setInterval(() => {
if(this.offsetY <= -this.headHeight) {
this.resetRefreshStatus();
clearInterval(intervalId);
this.logD("scrollByTop() finish, offsetY: " + this.offsetY.toFixed(2));
} else {
this.offsetY = ((this.offsetY - this.offsetStep) < -this.headHeight) ? (-this.headHeight) : (this.offsetY - this.offsetStep);
}
}, this.intervalTime);
} else {
this.logD("scrollByTop(): already scrolled to top edge")
}
}
private resetRefreshStatus() {
this.offsetY = -this.headHeight;
this.refreshHeadIcon = $r("app.media.icon_refresh_down");
this.refreshHeadText = Constant.REFRESH_PULL_TO_REFRESH;
this.setRefreshStatus(RefreshStatus.Inactive);
}
private finishRefresh(): void {
this.refreshHeadText = Constant.REFRESH_SUCCESS;
this.refreshHeadIcon = $r("app.media.icon_refresh_success");
this.setRefreshStatus(RefreshStatus.Done);
setTimeout(() => {
this.scrollByTop();
}, 1500);
}
aboutToAppear() {
if (this.refreshing) {
this.showRefreshingStatus();
}
}
private showRefreshingStatus() {
this.offsetY = 0;
this.refreshHeadIcon = $r("app.media.icon_refresh_loading");
this.refreshHeadText = Constant.REFRESH_REFRESHING;
this.setRefreshStatus(RefreshStatus.Refresh);
}
private notifyRefreshStart() {
if (this.onRefresh) {
this.onRefresh();
}
}
private notifyStatusChanged() {
if (this.onStatusChanged) {
this.onStatusChanged(this.refreshStatus);
}
}
private logD(msg: string) {
console.log(msg + ", canRefresh: " + this.canRefresh() + ", dragging: " + this.dragging + ", listScrollable: " + this.listScrollable + ", refreshing: " + this.refreshing);
}
}
9.8.5:RefreshList完整样例
笔者实现的 RefreshList 很简单,使用方式也和 Refresh 的用法保持了一致,唯一需要注意的就是想要自己实现 List 的每一个 item 局部,样例代码如下所示:
import {RefreshList} from "./widget/refresh_list"
@Entry @ComponentV2 struct ArkUIClubRefreshListTest {
@Local dataSet: Array<string> = [];
@Local refreshing: boolean = false;
@Builder itemLayout(item: Object, index: number) {
Text("item:" + item + ", index: " + index)
.width('100%')
.height(100)
.margin({top: 10})
.fontSize(16)
.textAlign(TextAlign.Center)
.borderRadius(10)
.backgroundColor('#bbccaa')
}
build() {
Column() {
Row({space: 10}) {
Button("开始刷新")
.onClick(() => {
this.refreshing = true;
})
Button("结束刷新")
.onClick(() => {
this.refreshing = false;
})
}
.width("100%")
.height(50)
Column() {
RefreshList({
refreshing: this.refreshing!!,
dataSet: this.dataSet,
itemLayout: (item: Object, index: number) => {
this.itemLayout(item, index);
},
onRefresh: () => {
this.doRefresh();
},
onStatusChanged: (status) => {
console.log("current status: " + status);
}
})
}
.width("100%")
.layoutWeight(1)
}
.width("100%")
.width('100%')
}
aboutToAppear() {
this.initDataSet(0, 10);
}
private doRefresh() {
setTimeout(() => {
console.log("finish refresh")
this.initDataSet((Math.random() * 100), 30);
this.refreshing = false;
}, 2500);
}
private initDataSet(start: number, count: number) {
let dataSet = new Array<string>();
for (let i = 0; i < count; i++) {
dataSet.push((i + start).toFixed(2));
}
this.dataSet = dataSet;
}
}
9.8.6:小结
本节笔者简单实现了一个下拉刷新控件 RefreshList,主要是利用了组件的 position()
方法实现动态布局,结合 onTouch()
方法实现下拉刷新,另外在 List 的 onScrollBegin()
方法内控制滑动值来解决滑动冲突,具体其它细节读者可自行阅读代码,笔者也期待读者能扩展出更多的功能,比如自定义刷新头,上拉加载更多等功能,或者读者自己实现一个 RefreshGrid 刷新控件……
备注
作者:灰太狼
來源:坚果派
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。否则追究相关责任。
- 0回答
- 1粉丝
- 0关注
- 自定义组件之<九>自定义下拉刷新上拉加载(RefreshLayout)
- 自定义组件之<七>自定义组件之插槽(slot)
- 自定义组件之<二>自定义圆环(Ring)
- 自定义组件之<六>自定义饼状图(PieChart)
- 自定义组件之<四>自定义对话框(Dialog)
- 自定义组件之<三>自定义标题栏(TitleBar)
- 自定义组件之<五>自定义对话框(PromptAction)
- 自定义组件之<十>发布开源库
- 自定义组件之<一>组件语法和生命周期
- @ComponentV2装饰器:自定义组件
- 鸿蒙自定义组件生命周期
- 页面和自定义组件生命周期
- HarmonyOS NEXT实战:自定义封装多种样式导航栏组件
- 如何加载和使用自定义字体
- 「Mac畅玩鸿蒙与硬件20」鸿蒙UI组件篇10 - Canvas组件自定义绘图