HarmonyOS鸿蒙Next中急急急急,Swiper 嵌套 Flex 再嵌套多个 List 滚动无法保持问题
HarmonyOS鸿蒙Next中急急急急,Swiper 嵌套 Flex 再嵌套多个 List 滚动无法保持问题

这是一个 Swiper 嵌套多个 Flex,然后每个 Flex 里面会嵌套 3个 List,然后每个 List 里面会嵌套 多个ListItem,每个 List 都是开启了垂直滚动。
现在的逻辑是,点击每个 ListItem ,会重新计算 pages 数据,然后重新渲染,这些逻辑都没问题,有问题的是重新渲染后 List 滚动条的位置保持不了,尝试过记录滚动条位置,然后再还原,发现还是会闪动,有什么其他方法吗?
import { AppModel } from "../../../../../../models/AppModel"
import { AppStorageV2, LengthMetrics, PersistenceV2 } from "@kit.ArkUI"
import { CacheModel } from "../../../../../../models/CacheModel"
import { ResearchService } from "../../../../../../services/ResearchService"
import { Base64Util, CryptoHelper, CryptoUtil, FileUtil, JSONUtil, MD5 } from "@pura/harmony-utils"
import { TreeNodeModel } from "../../../../../../models/TreeModel"
import fs from '@ohos.file.fs';
import { LogUtil } from "../../../../../../utils/LogUtil"
import { PageLoading } from "../../../../../../components/PageLoading"
import { TreeChangeEvent } from "../../../../../../events/TreeChangeEvent"
import { showToast } from "../../../../../../utils/HelperUtil"
import { drawing } from "@kit.ArkGraphics2D"
import { UserEvent } from "../../../../../../events/UserEvent"
import { TreeReloadEvent } from "../../../../../../events/TreeReloadEvent"
@ComponentV2
export struct Tree {
@Local appModel: AppModel =
AppStorageV2.connect(AppModel, () => new AppModel())!
@Local cacheModel: CacheModel =
PersistenceV2.connect(CacheModel, () => new CacheModel())!
@Local loading: boolean = true
@Local cacheFilePath: string = FileUtil.getCacheDirPath('tree', 'cache.json', false)
@Local originDataset: TreeNodeModel[] = []
@Local dataset: TreeNodeModel[] = []
@Local columns: TreeNodeModel[][] = []
@Local currentPage: number = 0
@Local pages: TreeNodeModel[][][] = []
@Local topScroller: ListScroller = new ListScroller()
@Local swiperController: SwiperController = new SwiperController()
@Local scrollerList: ListScroller[][] = []
@Local scrollerPosition: Record<string, number>[][] = []
aboutToAppear(): void {
TreeChangeEvent.on(this.handleActive)
TreeReloadEvent.on(this.handleReload)
UserEvent.on(this.handleReload)
this.fetch()
}
aboutToDisappear(): void {
TreeChangeEvent.off(this.handleActive)
TreeReloadEvent.off(this.handleReload)
UserEvent.off(this.handleReload)
}
handleActive = (): void => {
this.handleChangeActive(this.cacheModel.lastClickTreeNodeId)
}
handleReload = (): void => {
this.fetchNetwork()
}
fetch() {
try {
this.loading = true
const exists = fs.accessSync(this.cacheFilePath)
if (!exists) {
throw new Error('Cache file does not exist')
}
const cacheData = fs.readTextSync(this.cacheFilePath)
if (!cacheData) {
throw new Error('Cache file is empty')
}
this.originDataset = JSONUtil.jsonToArray<TreeNodeModel>(cacheData, TreeNodeModel)
this.handleChangeActive(this.cacheModel.lastClickTreeNodeId)
this.loading = false
} catch {
this.fetchNetwork()
}
}
fetchNetwork() {
this.loading = true
// 走网络请求
ResearchService.network().then(ret => {
if (!ret.isSuccess() || !ret.dataset?.length) {
return
}
fs.createStream(this.cacheFilePath, "w+").then(stream => {
stream.write(JSON.stringify(ret.dataset)).catch(() => {
LogUtil.error('Failed to write cache file')
})
}).catch(() => {
LogUtil.error('Failed to create cache file')
})
this.originDataset = ret.dataset
this.handleChangeActive(this.cacheModel.lastClickTreeNodeId)
}).finally(() => {
this.loading = false
})
}
handleChangeActive(forceActiveId: number) {
forceActiveId = forceActiveId || 0
// 列数据
let columns: TreeNodeModel[][] = []
// 原始数据构建
let originDataset = this.originDataset
// 递归重组树数据
const recursion = (children: TreeNodeModel[], layer = 0) => {
return children.map(item => {
if (item.children && item.children.length) {
item.children = recursion(item.children, layer + 1)
}
item.active = 0
// 指定打开到某个节点
if (Number(forceActiveId) && Number(forceActiveId) === Number(item.id)) {
item.active = 1
}
// 子节点被选中,父节点自动选中
(item.children || []).forEach(item2 => {
if (Number(item2.active)) {
item.active = 1
if (Number(item2.is_dir) === 0) {
item2.active = 0
}
}
})
// 节点高亮 & 存在子节点 加入到分页数据中
if (item.active && item.children && item.children.length) {
columns.unshift(item.children)
}
return item
})
}
let dataset = recursion([...originDataset])
const maxColumn = 3
if (dataset.length) {
// 构建列数据
const buildColumns = (
nodes: TreeNodeModel[] = [],
current = 0,
maxColumn = 3
) => {
let result: TreeNodeModel[][] = []
if (current >= maxColumn) {
return result
}
let activated = 0
let index = 0
if (nodes[index]) {
let children = nodes[index].children
for (let i = 0; i < nodes.length; i++) {
if (activated) {
break
}
const item = nodes[i]
if (Number(item.default_active)) {
activated = 1
index = i
children = item.children
}
}
nodes[index].active = 1
if (children && children.length) {
result = [children]
const nextColumns = buildColumns(children, current + 1, maxColumn)
result = [...result, ...nextColumns]
}
}
return result
}
const length = columns.length
if (length < maxColumn) {
let nodes = dataset
if (length > 0) {
nodes = columns[length - 1]
}
const otherColumns = buildColumns(nodes, 0, maxColumn)
columns = columns.concat(otherColumns).slice(0, maxColumn)
const lastColumnIndex = columns.length - 1
const lastColumn = columns[lastColumnIndex]
columns[lastColumnIndex] = lastColumn.map(item => {
item.active = 0
return item
})
}
}
// 视图分页数据
const pages: TreeNodeModel[][][] = []
const length = columns.length
if (length) {
pages.push(columns.slice(0, maxColumn))
if (length === maxColumn) {
const lastColumns = columns[maxColumn - 1]
for (let i = 0; i < lastColumns.length; i++) {
const item = lastColumns[i]
if (item.active) {
const pageData = [columns[maxColumn - 1]]
if (item.children) {
pageData.push(item.children)
}
pages.push(pageData)
break
}
}
} else {
const buildCols = columns.slice(maxColumn - 1)
for (let i = 0; i < buildCols.length - 1; i++) {
const pageData = [buildCols[i]]
if (buildCols[i + 1]) {
pageData.push(buildCols[i + 1])
}
pages.push(pageData)
}
}
}
const currentPage = pages.length - 1
const scrollerList: ListScroller[][] = []
pages.map((page, pageIndex) => {
let pageScrollerList: ListScroller[] = []
page.map((_, columnIndex) => {
pageScrollerList.push(new ListScroller())
})
scrollerList.push(pageScrollerList)
})
this.scrollerList = scrollerList
this.columns = columns
this.pages = pages
this.dataset = dataset
this.appModel.courseTreeCurrentPage = currentPage
this.cacheModel.lastClickTreeNodeId = forceActiveId
pages.map((page, pageIndex) => {
page.map((column, columnIndex) => {
const key = MD5.digestSync(column.map((item: TreeNodeModel) => item.id).join('-'))
const position = this.scrollerPosition?.[pageIndex]?.[columnIndex]?.[key] || 0
if (this.scrollerList?.[pageIndex]?.[columnIndex]) {
setTimeout(() => {
// this.scrollerList[pageIndex][columnIndex].scrollBy(0, position)
}, 10)
}
})
})
}
buildListWidth(columnIndex: number, pageIndex: number): Length {
if (pageIndex === 0) {
if (columnIndex === 0) {
return 110
} else if (columnIndex === 2) {
return 110
} else {
return 'auto'
}
} else {
if (columnIndex === 0) {
return 171
} else {
return 'auto'
}
}
}
buildListMargin(columnIndex: number, pageIndex: number): Margin | Length {
if (pageIndex > 0) {
if (columnIndex === 1) {
return {
right: 20,
}
}
}
return 0
}
buildListFlexShrink(columnIndex: number, pageIndex: number): number {
if (pageIndex === 0) {
if (columnIndex === 0) {
return 0
} else if (columnIndex === 2) {
return 0
} else {
return 1
}
} else {
if (columnIndex === 0) {
return 0
} else {
return 1
}
}
}
@Builder
buildListItemLeftIcon(item: TreeNodeModel, columnIndex: number, pageIndex: number) {
if (pageIndex === 0) {
if (columnIndex === 0) {
if (item.active) {
Image(item.icon)
.width(18)
.height(18)
.margin({ right: 5 })
.colorFilter(drawing.ColorFilter.createBlendModeColorFilter({
alpha: 255,
red: 255,
green: 255,
blue: 255,
}, drawing.BlendMode.SRC_IN))
} else {
Image(item.icon)
.width(18)
.height(18)
.margin({
right: 5
})
}
}
}
}
@Builder
buildListItemRightIcon(item: TreeNodeModel, columnIndex: number, pageIndex: number) {
if (!item.is_dir) {
if (item.finished) {
Image($r('app.media.tree_play_finished'))
.width(14)
.height(14)
.margin({
left: 5,
})
} else {
Image($r('app.media.tree_play'))
.width(14)
.height(14)
.margin({
left: 5,
})
}
} else if (!(pageIndex === 0 && columnIndex === 2)) {
if (item.active) {
Image($r('app.media.icon_arrow_white'))
.width(7)
.height(12)
.margin({
left: 5,
})
} else if (item.finished) {
Image($r('app.media.icon_arrow_gray'))
.width(7)
.height(12)
.margin({
left: 5,
})
} else {
Image($r('app.media.icon_arrow_blue'))
.width(7)
.height(12)
.margin({
left: 5,
})
}
}
}
@Builder
buildLineCanvas(columnIndex: number, pageIndex: number) {
if (pageIndex == 0) {
if (columnIndex == 1) {
Canvas()
.width(10)
.height('100%')
.flexShrink(0)
} else if (columnIndex === 2) {
Canvas()
.width(10)
.height('100%')
.flexShrink(0)
}
} else {
if (columnIndex === 0) {
Canvas()
.width(10)
.height('100%')
.flexShrink(0)
} else if (columnIndex === 1) {
Canvas()
.width(10)
.height('100%')
.flexShrink(0)
}
}
}
buildListItemNameFontSize(columnIndex: number, pageIndex: number): number {
if (pageIndex === 0) {
if (columnIndex === 0) {
return 12
}
}
return 11
}
buildListItemBorder(item: TreeNodeModel, columnIndex: number, pageIndex: number): BorderOptions {
if (pageIndex === 0) {
if (columnIndex === 0) {
return {
width: {
left: 0,
right: 1,
top: 1,
bottom: 1,
},
color: '#e7ebf6',
radius: {
topLeft: 0,
bottomLeft: 0,
topRight: 6,
bottomRight: 6,
}
}
} else if (columnIndex === 2) {
return {
width: {
left: 1,
right: 0,
top: 1,
bottom: 1,
},
color: '#e7ebf6',
radius: {
topLeft: 6,
bottomLeft: 6,
topRight: 0,
bottomRight: 0,
}
}
}
}
return {
width: 1,
color: '#e7ebf6',
radius: 6,
}
}
buildListItemStackPadding(item: TreeNodeModel): Padding {
if (item.node_type === 2) {
return {
top: 12,
}
}
return {}
}
buildListItemLinearGradient(item: TreeNodeModel, columnIndex: number, pageIndex: number): LinearGradientOptions {
let color1: ResourceColor = Color.White
let color2: ResourceColor = Color.White
let color3: ResourceColor = Color.White
let color4: ResourceColor = Color.White
if (item.study) {
color1 = '#e6e6e6'
color2 = '#e6e6e6'
}
if (item.finished) {
color1 = '#eaecf2'
color2 = '#eaecf2'
color3 = '#eaecf2'
color4 = '#eaecf2'
}
if (pageIndex === 0 && columnIndex === 0) {
color1 = Color.White
color2 = Color.White
color3 = Color.White
color4 = Color.White
}
if (item.active) {
color1 = '#254182'
color2 = '#254182'
color3 = '#254182'
color4 = '#254182'
}
return {
angle: 90,
colors: [
[color1, 0],
[color2, 0.55],
[color3, 0.65],
[color4, 1],
]
}
}
@Builder
buildColumn(column: TreeNodeModel[], columnIndex: number, pageIndex: number) {
this.buildLineCanvas(columnIndex, pageIndex)
List({
scroller: this.scrollerList[pageIndex]?.[columnIndex],
}) {
ForEach(column, (item: TreeNodeModel) => {
ListItem() {
Stack() {
Flex({ alignItems: ItemAlign.Center }) {
this.buildListItemLeftIcon(item, columnIndex, pageIndex)
Text(item.name)
.fontSize(this.buildListItemNameFontSize(columnIndex, pageIndex))
.fontColor(item.active ? Color.White : item.finished ? '#777777' : '#333333')
.layoutWeight(1)
this.buildListItemRightIcon(item, columnIndex, pageIndex)
}
.zIndex(2)
.width('100%')
.constraintSize({
minHeight: 50,
})
.padding({
left: 5,
right: 5,
top: 7,
bottom: 7,
})
.linearGradient(this.buildListItemLinearGradient(item, columnIndex, pageIndex))
.align(Alignment.Center)
.margin({
bottom: 8,
})
.border(this.buildListItemBorder(item, columnIndex, pageIndex))
.onClick(() => {
if (item.active) {
return
}
if (!item.is_dir) {
if (!item.course_id) {
showToast("视频不在课程下")
return
}
return
}
if (!item.children || !item.children.length) {
showToast("没有更多内容")
return
}
this.handleChangeActive(Number(item.id))
})
if (item.node_type == 2) {
Image(item.active ? $r('app.media.icon_course_blue') : $r('app.media.icon_course_white'))
.width(33)
.height(15)
.position({
x: 0,
y: -12,
})
.zIndex(1)
}
if (item.highlight && columnIndex < 2) {
Image($r('app.media.icon_new'))
.width(15)
.height(15)
.position({
right: 0,
top: 0,
})
.zIndex(3)
.border({
radius: {
topRight: 6
}
})
}
}
.padding(this.buildListItemStackPadding(item))
}
})
}
.width(this.buildListWidth(columnIndex, pageIndex))
.flexShrink(this.buildListFlexShrink(columnIndex, pageIndex))
.height('100%')
.listDirection(Axis.Vertical)
.scrollBar(0)
.align(Alignment.TopStart)
.margin(this.buildListMargin(columnIndex, pageIndex))
.onDidScroll(() => {
const key = MD5.digestSync(column.map((item: TreeNodeModel) => item.id).join('-'))
if (!this.scrollerPosition[pageIndex]) {
this.scrollerPosition[pageIndex] = []
}
if (!this.scrollerPosition[pageIndex][columnIndex]) {
this.scrollerPosition[pageIndex][columnIndex] = {}
}
this.scrollerPosition[pageIndex][columnIndex][key] = this.scrollerList[pageIndex][columnIndex].currentOffset().yOffset
LogUtil.debug(this.scrollerPosition)
})
}
@Builder
buildTop() {
List({ scroller: this.topScroller }) {
ForEach(this.dataset, (item: TreeNodeModel, i) => {
ListItem() {
Stack() {
Text(item.name)
.font({
size: this.dataset.length > 4 ? 14 : 16,
weight: item.active ? FontWeight.Bold : FontWeight.Normal,
})
.fontColor(item.active ? "#254182" : "#606060")
.zIndex(2)
if (item.active) {
Flex()
.width(41)
.height(8)
.borderRadius({
topRight: 4,
bottomRight: 4
})
.linearGradient({
angle: 90,
colors: [
['#00fa6400', 0],
['#FA6400', 1],
]
})
.margin({
top: 15
})
.zIndex(1)
}
}
.width('100%')
.height('100%')
.align(Alignment.Center)
}
.width(this.dataset.length > 4 ? '20%' : '25%')
.height('100%')
.align(Alignment.Center)
.onClick(() => {
if (item.active) {
return
}
this.topScroller.scrollToIndex(i, true, ScrollAlign.CENTER)
this.handleChangeActive(Number(item.id))
})
})
}
.width('100%')
.height(45)
.listDirection(Axis.Horizontal)
.backgroundColor(Color.White)
.align(Alignment.TopStart)
.scrollBarWidth(0)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(40) })
.borderWidth({
bottom: 1,
})
.borderColor({
bottom: "#f0f4fc"
})
.flexShrink(0)
.margin({
bottom: 7
})
}
showNextPageArrow() {
let showNextPage = !!this.pages[this.appModel.courseTreeCurrentPage + 1]
let hasActive = false
let firstHasChildrenNode: TreeNodeModel = {}
if (!showNextPage && this.pages.length) {
if (this.appModel.courseTreeCurrentPage === this.pages.length - 1) {
const columns = this.pages[this.appModel.courseTreeCurrentPage]
const lastColumns: TreeNodeModel[] = columns[columns.length - 1]
for (let i = 0; i < lastColumns.length; i++) {
const item = lastColumns[i]
if (item.active) {
hasActive = true
}
if (!firstHasChildrenNode.id && item.children && item.children.length) {
firstHasChildrenNode = item
}
}
if (!hasActive && firstHasChildrenNode.id) {
showNextPage = true
}
}
}
return showNextPage;
}
handleNextPageArrowClick() {
let showNextPage = !!this.pages[this.appModel.courseTreeCurrentPage + 1]
let hasActive = false
let firstHasChildrenNode: TreeNodeModel = {}
if (!showNextPage && this.pages.length) {
if (this.appModel.courseTreeCurrentPage === this.pages.length - 1) {
const columns = this.pages[this.appModel.courseTreeCurrentPage]
const lastColumns: TreeNodeModel[] = columns[columns.length - 1]
for (let i = 0; i < lastColumns.length; i++) {
const item = lastColumns[i]
if (item.active) {
hasActive = true
}
if (!firstHasChildrenNode.id && item.children && item.children.length) {
firstHasChildrenNode = item
}
}
if (!hasActive && firstHasChildrenNode && firstHasChildrenNode.id) {
showNextPage = true
}
}
}
if (firstHasChildrenNode.id) {
this.handleChangeActive(Number(firstHasChildrenNode.id))
} else {
this.appModel.courseTreeCurrentPage = this.appModel.courseTreeCurrentPage + 1
}
}
build() {
Stack() {
Flex({ direction: FlexDirection.Column }) {
this.buildTop()
Stack() {
Swiper(this.swiperController) {
ForEach(this.pages, (page: TreeNodeModel[][], pageIndex) => {
Flex() {
ForEach(page, (column: TreeNodeModel[], columnIndex) => {
this.buildColumn(column, columnIndex, pageIndex)
})
}
.height('100%')
})
}
.index(this.appModel.courseTreeCurrentPage)
.indicator(
new DotIndicator()
.itemWidth(20)
.itemHeight(4)
.selectedItemWidth(20)
.selectedItemHeight(4)
.space(LengthMetrics.vp(2))
.color('#c0c4cc')
.selectedColor('#254182'))
.loop(false)
.height('100%')
.onChange(index => {
this.appModel.courseTreeCurrentPage = index
})
Flex({ alignItems: ItemAlign.Center, justifyContent: FlexAlign.SpaceBetween }) {
Image($r('app.media.icon_tree_left'))
.width(42)
.height(42)
.transform({ scaleX: -1 })
.opacity(0.9)
.visibility(this.pages[this.appModel.courseTreeCurrentPage - 1] ? Visibility.Visible : Visibility.Hidden)
.onClick(() => this.appModel.courseTreeCurrentPage = this.appModel.courseTreeCurrentPage - 1)
Image($r('app.media.icon_tree_right'))
.width(42)
.height(42)
.opacity(0.9)
.visibility(this.showNextPageArrow() ? Visibility.Visible : Visibility.Hidden)
.onClick(() => this.handleNextPageArrowClick())
}
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
.align(Alignment.Center)
.flexShrink(1)
Flex({ alignItems: ItemAlign.Center }) {
Image($r('app.media.icon_tips'))
.width(9)
.height(9)
.margin({
right: 3,
top: 2
})
Text("节点提示:白色-未学习,灰白过渡色-未学完,灰色-已学完")
.fontSize(10)
.fontColor('#777777')
}
.backgroundColor("#eaecf2")
.padding(8)
.flexShrink(0)
}
.width('100%')
.height('100%')
PageLoading({ loading: this.loading })
}
.width('100%')
.height('100%')
.backgroundColor("#f0f4fc")
}
}
更多关于HarmonyOS鸿蒙Next中急急急急,Swiper 嵌套 Flex 再嵌套多个 List 滚动无法保持问题的实战教程也可以访问 https://www.itying.com/category-93-b0.html
开发者您好,您可以使用LazyForEach代替Foreach减轻渲染压力,同时参考键值生成规则,为每个item生成一个唯一且持久的键值,避免出现渲染结果异常、渲染效率降低等问题。如果以上方案仍然无法解决问题,请及时反馈。
更多关于HarmonyOS鸿蒙Next中急急急急,Swiper 嵌套 Flex 再嵌套多个 List 滚动无法保持问题的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html
不是同一个问题
开发者您好,您描述中提供的demo无法运行,请提供一个可复现问题的完整demo,方便问题分析解决。
蹲一个后续,等大佬出现
在HarmonyOS Next中,Swiper嵌套Flex再嵌套多个List时,需确保Swiper的滑动方向设为Axis.Horizontal,每个List的滑动方向设为Axis.Vertical。同时,禁用Swiper的嵌套滚动拦截属性(如nestedScrollEnabled(false)),避免事件冲突。若仍无法保持,可对List设置scrollEnabled(true)并检查Flex布局参数。,
问题出在每次点击更新数据时,handleChangeActive 里完全重建了 scrollerList 数组(new ListScroller()),导致之前绑定的滚动状态丢失。即便记录了偏移量,恢复时仍会触发一次跳变产生闪动。解决思路是避免重建 ListScroller:在组件初始化时预分配足够数量的 scroller,或仅在页数、列数增加时动态补充,保持已有 scroller 实例不变。同时确保 ForEach 的 key 使用稳定的 item.id,让 ArkUI 能复用 List 组件,这样滚动位置就会自然保持,不会闪动。

