阅读工作台已经能在折叠屏与平板间切换栏位,也能在再次进入应用时恢复文章和阅读锚点。但到了真正的长时间阅读场景,界面仍有一类容易被忽略的问题:用户在左侧列表切换文章,右侧的正文和批注要跟着变化;用户调整分栏比例,阅读位置不应清零;临时打开文章详情后返回,列表选中项与目录高亮需要指向同一个业务对象。若把每个区域当成独立页面实现,很容易在一次操作中产生两次路由跳转、两次网络请求或者两个互相覆盖的选中状态。

这一篇把焦点放在应用内部的分区布局与交互同步。分屏不是简单地在一个Row里摆放两组组件,而是为列表、正文、批注和导航建立清晰的状态所有权。用户的阅读选择是业务事实;窗口分栏比例、面板显隐和滚动条位置是展现状态,二者需要分层处理。

应用内分屏与交互同步封面

一、区分系统分栏和应用内部的多面板

HarmonyOS ArkUI的Navigation支持Stack、Split、Auto三种模式。Auto会依据设备窗口特征决定导航区域与目的页面如何呈现,适合主从路由。另一种需求是在已经显示的工作台页面中,让目录、正文及批注占用可调节的几个区域。这时使用Row、Column以及响应式宽度组织业务面板,更容易精确控制面板间的联动。

需求处理层应保持稳定的对象
从文章列表进入详情Navigation与NavPathStack文章ID、目的页面名称
宽屏展示列表和正文应用内部布局容器当前文章快照
拖动分栏分界线布局状态文章ID、语义锚点
在批注区选中章节阅读业务仓库锚点与请求版本
从宽屏转为紧凑窗口窗口条件和路由适配阅读会话快照

需要避免在同一个区域同时使用NavigationMode.Split和一套独立的左右主从布局,重复切分会造成额外空白和难以判断的回退行为。前面连载建立的NavigationMode.Auto仍可以管理顶层页面;当某个工作台需要精细的多面板时,可以在该页面内部采用应用级布局。下文代码采用内部Row与紧凑模式的详情路由两种呈现方式,便于观察同步行为。

二、统一保存文章、锚点与面板状态

真正需要共享的数据不多:选中articleId,正文anchorId以及一次交互形成的revision。面板宽度和批注开关可以保留在工作台的局部状态里。这样即使调整宽度造成组件树重建,也不会意外切换文章;业务层更新时则返回一个新快照,让ArkUI能够感知变化。

export class ReaderSnapshot {
  constructor(
    public articleId: string,
    public anchorId: string,
    public revision: number,
    public categoryId: string = 'all'
  ) {}
}

export class ReaderSessionStore {
  private current: ReaderSnapshot =
    new ReaderSnapshot('article-01', 'intro', 0)

  snapshot(): ReaderSnapshot {
    const s = this.current
    return new ReaderSnapshot(s.articleId, s.anchorId,
      s.revision, s.categoryId)
  }

  selectArticle(articleId: string): ReaderSnapshot {
    if (articleId === this.current.articleId) {
      return this.snapshot()
    }
    this.current = new ReaderSnapshot(articleId, 'intro',
      this.current.revision + 1, this.current.categoryId)
    return this.snapshot()
  }

  moveAnchor(anchorId: string): ReaderSnapshot {
    this.current = new ReaderSnapshot(this.current.articleId,
      anchorId, this.current.revision + 1, this.current.categoryId)
    return this.snapshot()
  }
}

revision用于拒绝迟到的异步正文结果,并不是文章的永久版本号。切换文章时锚点重置为文章开头;在同一篇文章中点击目录,则只更新锚点。若要恢复上一次阅读位置,可以把第六篇保存到Preferences中的快照映射到这里,随后通过仓库校验文章是否仍存在。不要让分栏组件自行读取、修改永久存储,它只负责当前布局。

三、工作台以一个选择入口协调两侧

下面的核心组件用单个select()处理列表点击。宽屏时正文作为相邻面板更新;窄屏时使用NavPathStack进入详情。为了避免重复点击叠加多个详情页面,紧凑模式在选择新文章时先清理当前工作台维护的目的路径。实际项目可以在这一步进一步保留其它独立页面的路由,而不是粗粒度清空全应用栈。

import { mediaquery } from '@kit.ArkUI'

@Entry
@Component
struct ReadingWorkbench {
  @State private selectedId: string = 'article-01'
  @State private isWide: boolean = false
  @State private leftPercent: number = 38
  @State private pathStack: NavPathStack = new NavPathStack()
  private widthListener?: mediaquery.MediaQueryListener

  private onWidthChange = (event: mediaquery.MediaQueryResult): void => {
    if (this.isWide === event.matches) {
      return
    }
    this.isWide = event.matches
    this.pathStack.clear()
    if (!this.isWide && this.selectedId.length > 0) {
      this.pathStack.pushPathByName('ArticleDetail', this.selectedId)
    }
  }

  aboutToAppear(): void {
    this.widthListener = mediaquery.matchMediaSync('(min-width: 840vp)')
    this.isWide = this.widthListener.matches
    this.widthListener.on('change', this.onWidthChange)
  }

  aboutToDisappear(): void {
    this.widthListener?.off('change', this.onWidthChange)
  }

  private select(id: string): void {
    if (this.selectedId === id) {
      return
    }
    this.selectedId = id
    if (!this.isWide) {
      this.pathStack.clear()
      this.pathStack.pushPathByName('ArticleDetail', id)
    }
  }

  @Builder
  private listPane() {
    Column({ space: 12 }) {
      Text('文章列表').fontSize(22).fontWeight(FontWeight.Bold)
      ForEach(['article-01', 'article-02', 'article-03'], (id: string) => {
        Button(id === this.selectedId ? `正在阅读 ${id}` : id)
          .onClick(() => this.select(id))
      })
    }.padding(16).width('100%')
  }

  @Builder
  private detailPane() {
    Column({ space: 12 }) {
      Text('文章详情').fontSize(22).fontWeight(FontWeight.Bold)
      Text(`当前文章:${this.selectedId}`)
      Text('正文、章节目录和批注入口放在当前区域')
    }.padding(16).width('100%')
  }

  @Builder
  private destination(name: string) {
    if (name === 'ArticleDetail') {
      NavDestination() {
        this.detailPane()
      }.title('文章详情')
    }
  }

  build() {
    Navigation(this.pathStack) {
      if (this.isWide) {
        Row() {
          Column() { this.listPane() }
            .width(`${this.leftPercent}%`)
          Divider().vertical(true).width(1)
          Column() { this.detailPane() }.layoutWeight(1)
        }.width('100%').height('100%')
      } else {
        this.listPane()
      }
    }
    .mode(NavigationMode.Stack)
    .navDestination(this.destination)
    .title('阅读工作台')
  }
}

这里NavigationMode.Stack是针对工作台内部已经有自定义分栏的情况;如果页面改由NavigationMode.Auto管理主从布局,就应相应移除内部重复的主从分栏。宽屏切换到紧凑模式时,onWidthChange保留所选文章并重建与当前显示形态匹配的目的路径。布局判定使用窗口媒体条件,而不是按设备型号写死。

DevEco Studio中工作台组件与分栏预览

四、分栏比例改变只更新布局参数

拖动分割线最容易发生的错误,是把拖拽事件等同于“重新进入详情”。一旦每次宽度变化都执行pushPathByName或重新请求正文,快速拖拽会导致大量无效业务操作。正确做法是让比例变化只修改leftPercent,文章选择与网络请求保持不变。

export function clampSplitRatio(value: number): number {
  return Math.min(55, Math.max(28, Math.round(value)))
}

export class SplitGestureState {
  private startPercent: number = 38
  begin(currentPercent: number): void {
    this.startPercent = currentPercent
  }
  move(deltaVp: number, wholeWidthVp: number): number {
    if (wholeWidthVp <= 0) {
      return this.startPercent
    }
    const deltaPercent: number = deltaVp * 100 / wholeWidthVp
    return clampSplitRatio(this.startPercent + deltaPercent)
  }
}

这个纯计算层可以与PanGesture的更新事件配合使用。它将左栏限制在28%至55%,防止阅读正文被挤得过窄。窗口收缩到单栏后,保留比例但不强制恢复分隔线;下次重新进入宽屏时再应用已记录的比例。工程上还应把一次拖拽中的高频变化和落手后的持久化区分开:交互期间更新当前UI,结束时才保存最终比例。

五、使用请求代际阻断跨面板旧数据

左侧快速选择“文章A—文章B—文章C”时,返回顺序可能是C、A、B。无论列表与正文如何布局,最终详情必须属于C。用一个递增的请求代际过滤过期结果,比在每个子组件里各自设置loading更清晰。

export class ArticleDetail {
  constructor(public id: string, public content: string) {}
}

export class DetailCoordinator {
  private requestEpoch: number = 0
  private currentId: string = ''

  async open(id: string,
    load: (id: string) => Promise<ArticleDetail>): Promise<ArticleDetail | null> {
    const epoch = ++this.requestEpoch
    this.currentId = id
    try {
      const detail = await load(id)
      if (epoch !== this.requestEpoch || detail.id !== this.currentId) {
        return null
      }
      return detail
    } catch (error) {
      if (epoch !== this.requestEpoch) {
        return null
      }
      throw error
    }
  }
}

协调器只返回仍然有效的结果。页面接收到null后保留当前正文和加载状态,不应拿旧文章的数据填充新详情;当前请求失败时则显示可重试的提示,并继续保留文章ID。若分栏右侧还有批注面板,批注查询应携带同一文章ID,并使用自己的请求代际处理顺序,而不是与正文共用临时组件索引。

六、阅读位置同步应使用章节锚点

两侧联动不意味着“同步两个滚动条的像素值”。宽屏正文每行字数较多,紧凑模式的同一段落高度可能翻倍;直接复制滚动像素会跳到别的章节。更稳妥的是在正文布局完成后定位到anchorId对应的语义节点。目录点击会更新锚点;正文滚动到新章节时再向会话仓库回写当前锚点。需要给回写加入来源标识或短时间合并策略,防止“目录更新正文—正文回调再更新目录”形成循环。

操作应更新的数据不应触发的动作
列表选中另一篇articleId、请求代际、初始锚点反复创建整个工作台
目录点击章节anchorId改写文章ID
改变分栏宽度面板比例再次请求全文
从单栏返回双栏当前布局模式覆盖最后一次阅读快照
正文异步加载完成当前详情数据接受旧请求覆盖新文章

移动界面中的文章选择与阅读进度

七、排查交互错位的实际顺序

出现左右内容不一致时,先看业务快照里的文章ID和请求代际,再检查路由栈,最后检查UI是否按新快照刷新。不要先调整布局尺寸掩盖数据层错误。若在连续切换栏位后出现多个详情,检查onWidthChange是不是每次窗口事件都推入路径;若列表高亮正确但详情仍显示旧文,检查是否有迟到请求绕过代际判断;若在分栏拖动时阅读位置跳动,检查是否将比例变化直接绑定到文章重新加载。

还有一种值得单独排查的情况:右侧默认空白。分栏模式下没有有效目的页面时,可使用Navigation提供的分栏占位能力,或在应用内部给详情区提供明确的“请选择文章”空态。不要在初始化时强制选中列表第一项,用户此前保存的阅读会话应该优先恢复。对于恢复数据已经失效的情况,再进入可解释的空态或重新选择流程。

到这里,双栏阅读工作台的核心交互链路已经打通:路由负责前后关系,工作台布局负责空间安排,业务仓库负责阅读事实,异步协调器负责时间顺序。下一篇将进一步梳理渲染频率、异常回归和不同窗口形态下的性能问题,形成可持续维护的阅读应用方案。

相关技术文档:Navigation分栏页面留白处理、NavDestination页面模式、多设备页面适配。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐