折叠屏适配踩坑实录:从“一展开就变形“到一套代码适配多种形态【鸿蒙心迹】

折叠屏适配踩坑实录:从"一展开就变形"到一套代码适配多种形态【鸿蒙心迹】

写在前面

资讯应用上线后,一条折叠屏反馈戳中了我:展开大屏浏览时,图片被拉变形,页面两侧空荡荡留着大片白,一眼就能看出是手机布局直接硬拉伸到大屏上。 上手真机复现,问题很明确:折叠小屏状态表现没问题;展开后单列布局占满全宽,封面图等比约束失效、出现拉伸;详情文本单行过长,阅读起来很吃力。 花了两周时间,把多设备形态适配重新梳理一轮,踩了不少坑。下面以「现象 - 原因 - 解法」的思路记录,附带项目抽出来的精简 ArkTS 代码(Stage 模型),希望能帮做折叠屏适配的伙伴少踩雷。

坑一:用屏幕宽度判断布局

最初版本的写法很直接:

typescript

复制代码
aboutToAppear(): void {
  const screenWidth = px2vp(display.getDefaultDisplaySync().width);
  this.columns = screenWidth >= 600 ? 2 : 1;
}

这段代码有两个问题。第一个比较明显:只在 aboutToAppear 里算一次,折叠或展开后根本不会刷新。

第二个更隐蔽:它取的是屏幕宽度 ,不是窗口宽度。展开态下进入分屏或悬浮窗时,屏幕依然很宽,但应用窗口可能只有一半甚至更小,结果在一个窄窗口里排出了双列,卡片挤成一团。

结论是:布局应该以应用窗口尺寸为准,而不是屏幕尺寸,更不是设备类型。我们参考"一次开发,多端部署"的思路,按窗口宽度划分断点:

断点 窗口宽度 典型场景
xs < 320vp 极窄的悬浮窗
sm 320vp ~ 600vp 直板机、折叠屏折叠态
md 600vp ~ 840vp 折叠屏展开态、较大的分屏窗口
lg ≥ 840vp 平板、2in1 设备

坑二:只监听折叠状态

第二版我改成了监听 display.on('foldStatusChange')。折叠和展开确实能正确响应了,但测试很快发现:分屏、悬浮窗缩放、横竖屏切换时,窗口尺寸变了,折叠状态却没变,回调根本不触发。

这时候我才意识到:折叠状态和布局是两回事 。布局真正关心的只有"我的窗口现在多宽"。所以最终方案是在 EntryAbility 里统一监听 windowSizeChange,算出断点后写入 AppStorage,所有页面共用:

typescript

复制代码
import { UIAbility } from '@kit.AbilityKit';
import { display, window } from '@kit.ArkUI';

export default class EntryAbility extends UIAbility {
  private mainWindow?: window.Window;

  // 按窗口宽度(vp)计算断点,只有断点真正变化时才写入 AppStorage
  private updateBreakpoint(windowWidthPx: number): void {
    const density = display.getDefaultDisplaySync().densityPixels;
    const widthVp = windowWidthPx / density;
    let bp: string = 'lg';
    if (widthVp < 320) {
      bp = 'xs';
    } else if (widthVp < 600) {
      bp = 'sm';
    } else if (widthVp < 840) {
      bp = 'md';
    }
    if (AppStorage.get<string>('currentBreakpoint') !== bp) {
      AppStorage.setOrCreate<string>('currentBreakpoint', bp);
    }
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    try {
      this.mainWindow = windowStage.getMainWindowSync();
      // 首次进入先算一次,避免页面先用默认值渲染再"闪"一下
      this.updateBreakpoint(this.mainWindow.getWindowProperties().windowRect.width);
      this.mainWindow.on('windowSizeChange', (size: window.Size) => {
        this.updateBreakpoint(size.width);
      });
    } catch (err) {
      console.error(`init breakpoint failed: ${JSON.stringify(err)}`);
    }
    windowStage.loadContent('pages/Index');
  }

  onWindowStageDestroy(): void {
    this.mainWindow?.off('windowSizeChange');
  }
}

这里有两个小细节。一是 windowSizeChange 回调里的宽度单位是 px,必须换算成 vp 再比较,否则高密度屏上断点会整体偏移。二是只在断点真正变化时才写 AppStorage,因为拖动悬浮窗边缘时这个回调触发得非常频繁,每次都写会导致整页反复刷新。

页面里用一行就能拿到当前断点:

typescript

复制代码
@StorageProp('currentBreakpoint') currentBreakpoint: string = 'sm';

如果你的项目最低支持版本在 API 13 及以上,也可以直接用 UIContext.getWindowWidthBreakpoint() 获取窗口宽度断点。我们因为要兼容旧版本,保留了自己维护的方案。

坑三:列数切换后,滚动位置"瞬移"

断点有了,首页列表改成根据断点切换 1 / 2 / 3 列。这时测试同学报了一个奇怪的问题:在折叠态滑到第 30 条左右,展开后,屏幕上显示的却是第 60 条附近的内容。

原因是列数切换后 List 重新布局,内容总高度大约变成了原来的一半,但列表保留的是原来的滚动偏移量。同样的偏移量在双列布局里对应的已经是完全不同的内容。

解法是记录"语义位置"而不是像素位置:用 onScrollIndex 记下首个可见项的索引,断点变化后再用 scrollToIndex 定位回去。

typescript

复制代码
@Component
export struct NewsList {
  @StorageProp('currentBreakpoint') @Watch('onBreakpointChange') currentBreakpoint: string = 'sm';
  @Prop newsList: NewsItem[] = [];
  private scroller: Scroller = new Scroller();
  private firstVisibleIndex: number = 0;

  onBreakpointChange(): void {
    const target = this.firstVisibleIndex;
    // 等新的列数布局生效后,再按"索引"把位置找回来
    setTimeout(() => {
      this.scroller.scrollToIndex(target);
    }, 0);
  }

  private getLanes(): number {
    if (this.currentBreakpoint === 'lg') {
      return 3;
    }
    if (this.currentBreakpoint === 'md') {
      return 2;
    }
    return 1;
  }

  build() {
    List({ space: 12, scroller: this.scroller }) {
      ForEach(this.newsList, (item: NewsItem) => {
        ListItem() {
          NewsCard({ item: item })
        }
      }, (item: NewsItem) => item.id)
    }
    .lanes(this.getLanes(), 12)
    .onScrollIndex((start: number) => {
      this.firstVisibleIndex = start;
    })
    .padding({ left: 16, right: 16 })
  }
}

注意 setTimeout 这一步不能省。@Watch 回调触发时新布局还没完成,立刻调用 scrollToIndex 会按旧布局去定位,效果时好时坏。

坑四:图片变形与正文过宽

封面图的问题出在原来的写法 .width('100%').height(180).objectFit(ImageFit.Fill)。当初为了"填满"用了 Fill,展开后宽度翻倍、高度不变,图片自然被横向拉伸。改成固定宽高比加 Cover 之后,任何宽度下都不会变形:

typescript

复制代码
Image(this.item.coverUrl)
  .width('100%')
  .aspectRatio(16 / 9)
  .objectFit(ImageFit.Cover)
  .borderRadius(8)

详情页正文过宽的问题,用栅格布局限制内容区宽度最省事:小屏占满,大屏居中并留出两侧空白。

typescript

复制代码
GridRow({
  columns: { xs: 4, sm: 4, md: 8, lg: 12 },
  gutter: { x: 16 },
  breakpoints: { value: ['320vp', '600vp', '840vp'], reference: BreakpointsReference.WindowSize }
}) {
  GridCol({
    span: { xs: 4, sm: 4, md: 6, lg: 8 },
    offset: { xs: 0, sm: 0, md: 1, lg: 2 }
  }) {
    ArticleContent({ article: this.article })
  }
}

这里我还踩了一个小坑:一开始我把 breakpoints.value 写成了 ['600vp', '840vp'],结果 md 的配置死活不生效。原因是 GridRow 的断点名是按顺序从 xs 开始分配的,两个分界值会切出三段,分别叫 xs、sm、md,而不是 sm、md、lg。断点名和自己 AppStorage 里的命名对不上,排查了半天。建议要么直接用默认值,要么像上面一样把分界值写全。

进阶:视频页的悬停态

折叠屏半折叠放在桌面上(悬停态)时,最理想的体验是视频在上半屏播放、控制栏和评论在下半屏。这个效果用 FolderStack 实现起来不复杂:把需要放到上半屏的子组件 id 填进 upperItems,其余组件在悬停时会被放到下半屏。

typescript

复制代码
@Component
struct VideoPage {
  @State isHalfFolded: boolean = false;

  build() {
    FolderStack({ upperItems: ['videoArea'] }) {
      Column() {
        VideoPlayerView()
      }
      .id('videoArea')

      Column() {
        PlayControlBar({ compact: !this.isHalfFolded })
        CommentList()
      }
    }
    .onFolderStateChange((event) => {
      this.isHalfFolded = event.foldStatus === FoldStatus.FOLD_STATUS_HALF_FOLDED;
    })
    .width('100%')
    .height('100%')
  }
}

这里有个容易忽略的点:非悬停状态下,FolderStack 的表现就是一个普通的 Stack,子组件会叠在一起。所以下半区的内容要按"浮层"的思路来设计,否则平时看起来会是视频和评论区重叠在一起。另外建议让 FolderStack 撑满整个窗口,并且一定要上真机做横向半折叠验证。

自测清单

适配完成后,我整理了一份每次发版前都会过一遍的清单:

  • 折叠 ↔ 展开来回切换 10 次以上,布局正确、页面状态不丢失
  • 展开态下分屏(不同比例)和悬浮窗拖拽缩放,断点切换正常
  • 列表滑到中部后切换形态,阅读位置保持不变
  • 视频播放中折叠或展开,播放不中断、不重新加载
  • 键盘弹出时切换形态,输入框不被遮挡
  • 横竖屏切换、深色模式、大字体下的展开态显示正常
  • 悬停态下上下半屏内容分区正确

总结

回头看,这次适配的核心经验可以浓缩成三句话。

第一,以窗口为准,不以屏幕为准。分屏、悬浮窗、平行视界都会让窗口和屏幕不一致,只看窗口宽度才不会出错。

第二,断点统一管理,只在变化时通知。在 Ability 层集中监听、集中计算,页面只消费结果,既避免了重复代码,也避免了高频刷新。

第三,状态按语义保存。滚动位置记索引不记偏移,图片记比例不记高度,形态切换时才不会"瞬移"和"变形"。

折叠屏适配看起来是 UI 问题,本质上是"让布局跟着窗口走"的思维转变。想通这一点之后,后面新增的页面基本都是一次写好,多种形态直接可用。

相关推荐
马剑威(威哥爱编程)2 小时前
【HarmonyOS 7 小艺智能体实战】03:第一次建智能体——在小艺开放平台把骨架立起来
人工智能·华为·harmonyos
KevinChaos3 小时前
HarmonyOS 7.0 端侧3DGS实验⑤:空洞、漂浮伪影与视角稳定
harmonyos
fellow993 小时前
DSH Desktop - HarmonyOS Edition
harmonyos·openharmony·deepseek·deepseekharness
鸿蒙开发3 小时前
鸿蒙表单异步校验实战:@hmkit/validator 1.3 的取消、超时与任务清理
harmonyos
Flutter_OH3 小时前
Flutter OHOS 核心对象架构原理说明
harmonyos
贾伟康4 小时前
【健康菜谱助手|04】HarmonyOS ArkTS 一周膳食计划实战:管理日期、餐次与菜谱替换的联动
软件测试·移动开发·harmonyos·arkts·arkui·1024程序员节
贾伟康4 小时前
【健康菜谱助手|02】HarmonyOS ArkTS 烹饪步骤实战:把分步操作、完成状态和返回恢复做稳
软件测试·移动开发·harmonyos·arkts·arkui
用户29469405448165 小时前
DSH Desktop - HarmonyOS Edition
harmonyos·deepseek
OH_TPC6 小时前
【鸿蒙优选三方库】@ohos/ffmpeg-kit:把 FFmpeg 的全部能量带进鸿蒙
华为·ffmpeg·harmonyos·鸿蒙