普通内容应用做横竖屏适配,往往是列表从一列变两列;编辑器更难,因为画布、工具栏、图层、调色板、时间轴、历史记录和弹窗都在争夺同一块屏幕。把竖屏尺寸按比例缩小后塞进横屏,不会得到更紧凑的编辑器,只会得到一组文字太小、按钮难点、面板互相遮挡的控件。

bitArt 的处理思路是先保证画布和主要操作,再根据窗口宽高选择手机竖屏、手机横屏、平板和二合一工作台。EntryAbility.ets 负责全屏窗口与顶部避让区;EditorLayoutService.ets 集中判断布局档位和面板尺寸;工作区组件只消费计算结果;PixelCanvasView.ets 则让画布在尺寸变化后重新计算基准比例、缩放和平移边界。

全屏不是忽略系统栏

像素编辑器希望尽量多留空间给画布,因此窗口开启全屏布局,并把状态栏与导航栏颜色设成工作台深色。全屏布局只表示内容可以延伸到系统栏区域,不表示顶部控件可以直接贴到屏幕物理边缘。

EntryAbility 分别读取系统避让区和挖孔避让区,取两者顶部高度的最大值,再写入 AppStorage。当 avoidAreaChange 发生时重新计算,旋转或窗口形态变化后也能更新。

private updateTopSafeArea(mainWindow: window.Window): void {
  let topSafeAreaPx = 0;
  const systemArea = mainWindow.getWindowAvoidArea(
    window.AvoidAreaType.TYPE_SYSTEM);
  topSafeAreaPx = Math.max(
    topSafeAreaPx, systemArea.topRect.height);

  const cutoutArea = mainWindow.getWindowAvoidArea(
    window.AvoidAreaType.TYPE_CUTOUT);
  topSafeAreaPx = Math.max(
    topSafeAreaPx, cutoutArea.topRect.height);

  AppStorage.setOrCreate<number>(
    'editorTopSafeAreaPx', topSafeAreaPx);
}

工作区再按当前显示密度把像素值换算为 vp,作为顶部工具栏和浮层的起点。这里不能把某个设备实测的 24 或 32 直接写死,因为刘海、挖孔、状态栏高度和窗口模式都可能不同。

布局判断要看窗口,不只看设备类型

平板可以分屏,二合一设备也可以把窗口缩小。如果只根据 deviceType 决定桌面布局,小窗口里仍会出现三栏工作台。EditorLayoutService 直接使用 viewportWidthviewportHeight:宽度至少 720 且高度至少 560 进入大屏布局;宽度至少 960 进入桌面档;桌面档且高度达到 700 时启用统一右侧面板。

static isLargeScreenLayout(state: EditorLayoutState): boolean {
  return state.viewportWidth >= 720 &&
    state.viewportHeight >= 560;
}

static isDesktopLayout(state: EditorLayoutState): boolean {
  return state.viewportWidth >= 960 &&
    state.viewportHeight >= 560;
}

static isUnifiedSidePanelLayout(state: EditorLayoutState): boolean {
  return EditorLayoutService.isDesktopLayout(state) &&
    state.viewportHeight >= 700;
}

横屏判断也不只是 width > height。bitArt 要求宽度比高度多出 40vp,或者已经满足大屏工作台条件。这个小缓冲可以减少接近方形窗口在轻微变化时反复切换布局。

手机竖屏先保画布,再让面板按需展开

竖屏空间最紧,不能同时常驻图层、调色板和时间轴。bitArt 把这些区域做成可开合面板,紧凑状态只露出必要入口;时间轴展开后占用固定高度,帧列表横向滚动;图层和调色板根据当前操作互相收纳。

画布区域使用剩余空间,而不是固定高度。顶部栏、底部工具和展开面板先占据可预测尺寸,中间画布通过 layoutWeight(1) 获取剩余区域,并配合 constraintSize({ minWidth: 0, minHeight: 0 }) 防止子内容反向撑大父容器。

Column() {
  this.TopBar()
  Row() {
    this.ToolRail()
    this.CanvasPanel()
      .layoutWeight(1)
      .constraintSize({ minWidth: 0, minHeight: 0 })
  }
  if (this.timelineOpen) {
    this.TimelinePanel('expanded')
  }
}

手机竖屏不是把所有功能隐藏,而是把低频内容延后到明确入口。设置、项目管理和素材库以浮层出现,关闭后立即把空间还给画布。

横屏要把高度问题交给滚动容器

横屏看起来更宽,实际高度更少。若侧栏把图层、调色板和帧列表纵向堆满,底部播放和新增按钮最容易被裁掉。bitArt 在横屏使用窄侧栏:图层和调色板内容可滚动,帧卡片放入两列网格,新增、删除、播放和展开操作固定在面板底部。

Scroll() {
  Grid() {
    ForEach(this.animationFrames, (item: EditorFrame) => {
      GridItem() {
        this.LandscapeFrameCard(item)
      }
    })
  }
  .columnsTemplate('1fr 1fr')
  .columnsGap(6)
  .rowsGap(6)
}
.layoutWeight(1)
.scrollBar(BarState.Auto)

this.LandscapeFrameDurationControl()
this.FixedTimelineActions()

关键点是滚动区域必须有可计算高度。只给内部 ColumnScroll,外层却没有高度或权重约束,滚动容器会按内容无限展开,结果仍然溢出。

大屏工作台不是放大的手机界面

达到大屏阈值后,bitArt 采用左侧工具、中间画布、右侧面板的三区域工作台。右侧可以常驻图层、调色板和时间轴,用户不必频繁开关面板。桌面宽度达到 1280 时,统一侧栏从 236 增到 252;普通大屏档则使用 188 或 220 的侧栏宽度。

这不是简单增大控件。画布仍然是视觉中心,侧栏增加的是信息密度和并行可见性。设置面板、项目管理和素材库在大屏上也使用更宽的内容区,允许两列或更多信息,但高度仍受当前窗口限制。

键盘快捷键只在大屏或键盘友好布局中开放。手机界面不显示一整套快捷键设置,避免为不存在的输入设备占用空间。二合一设备的鼠标滚轮则直接作用于画布缩放。

浮层尺寸必须同时受宽度和高度约束

设置面板在竖屏可用 340×520,横屏可能更宽却只能更矮;桌面面板希望达到 720 宽,但窗口分屏后不能越界。EditorLayoutService 集中计算可用尺寸:宽度扣除两侧安全保护和边缘间距,高度扣除顶部偏移、底部保护和边缘间距,再在首选值与最小值之间裁剪。

static getFloatingPanelHeight(state: EditorLayoutState,
  preferredHeight: number, minHeight: number,
  topOffset: number = 0): number {
  const availableHeight =
    state.viewportHeight - topOffset -
    EditorLayoutService.BOTTOM_GUARD_VP - 8;
  if (availableHeight < minHeight) {
    return Math.max(1, availableHeight);
  }
  return Math.min(preferredHeight, availableHeight);
}

面板内部仍要可滚动,因为尺寸计算只能保证外框不越界,不能保证所有设置项一次显示完。底部保护值还能让主要按钮避开圆角和手势区域。

画布尺寸变化后要重算基准比例

工作区从竖屏切到横屏,画布组件的可用宽高会变化。PixelCanvasView 在区域变化时更新 viewportWidthviewportHeight,通过 EditorViewportService.getCanvasBaseScale 重新计算画布基础宽高,然后重新夹紧平移量并安排完整重绘。

private updateCanvasSide(width: number, height: number): void {
  this.viewportWidth = Math.max(1, width);
  this.viewportHeight = Math.max(1, height);
  const baseScale = EditorViewportService.getCanvasBaseScale(
    width, height, this.canvasWidth, this.canvasHeight);
  this.canvasBaseWidth = Math.max(1,
    this.canvasWidth * baseScale);
  this.canvasBaseHeight = Math.max(1,
    this.canvasHeight * baseScale);
  this.updatePan(this.panOffsetX, this.panOffsetY);
  this.scheduleCanvasRedraw(true);
}

非方形画布尤其需要同时使用宽度和高度。如果仍按单一 canvasSize 计算,32×64 的画布会被当成正方形,触控映射和视觉位置都会偏移。

平移边界只在画布超出视口时开放

缩放后的画布小于视口时,不应该允许拖到角落;大于视口时,又要允许查看被裁切部分。updatePan 把每个轴分别交给 EditorViewportService.clampPanOffset,依据视口和实际绘制尺寸计算范围。

鼠标滚轮缩放还要围绕光标位置。bitArt 先计算光标在旧画布中的相对锚点,再算新尺寸下需要的偏移,使光标指向的像素尽量留在原位。

const anchorX = (x - previousLeft) / previousDrawWidth;
const anchorY = (y - previousTop) / previousDrawHeight;
this.zoomScale = EditorViewportService.clampZoom(zoom);
const nextPanOffsetX = x -
  (this.viewportWidth - nextDrawWidth) / 2 -
  anchorX * nextDrawWidth;
const nextPanOffsetY = y -
  (this.viewportHeight - nextDrawHeight) / 2 -
  anchorY * nextDrawHeight;
this.updatePan(nextPanOffsetX, nextPanOffsetY);

双指缩放、双指平移、移动工具和临时空格平移都复用同一组缩放与边界函数,避免不同输入方式把画布推到不同的非法位置。

性能适配也是布局适配的一部分

横屏和大屏能显示更多面板,也意味着更多缩略图、洋葱皮和覆盖层同时存在。如果每次画笔移动都让全部区域重新合成,设备越大反而越容易闪烁。bitArt 把移动触摸按约 16 毫秒合并,画布重绘也用定时器合并;有有效脏区域时只更新局部,否则才完整重绘。

private scheduleCanvasRedraw(fullRedraw: boolean): void {
  this.pendingFullRedraw = this.pendingFullRedraw || fullRedraw;
  if (this.redrawTimerId >= 0) {
    return;
  }
  this.redrawTimerId = setTimeout(() => {
    this.redrawTimerId = -1;
    this.captureDirtyBounds(true);
    if (this.pendingFullRedraw || !this.drawDirtyCanvas()) {
      this.drawCanvas();
    }
    this.pendingFullRedraw = false;
  }, 16);
}

正在绘制时暂时跳过像素溢色和洋葱皮等昂贵效果,笔触结束再恢复。面板开合、窗口旋转和缩放属于几何变化,需要完整重绘;单个像素变化则优先走脏区域。这种区分比单纯降低画质更能保持编辑手感。

适配检查要覆盖窗口组合

只在一台手机竖屏看起来正常还不够。至少要检查手机竖屏、手机横屏、平板全屏、平板分屏、二合一宽窗口和接近方形的小窗口。每种组合都要观察顶部栏是否避开挖孔、底部按钮是否高于手势区、画布是否保持完整比例、侧栏能否滚到底、弹窗是否越界、长项目名是否截断,以及旋转后缩放和平移是否仍在合理范围。

交互检查也要跟着布局走:横屏增加到十帧,确认播放按钮仍可达;图层增加到二十层,确认列表滚动而不是挤压画布;打开颜色选择器和设置面板,确认确认按钮没有落到屏外;大屏使用鼠标滚轮围绕光标缩放;退到小窗口后确认快捷键设置不会占据手机布局。

bitArt 在 module.json5 中声明 phonetablet2in1,并允许 auto_rotation。这只是系统层面的入口,真正的适配仍由窗口数据、布局档位、滚动边界和画布几何共同完成。

复杂编辑器的响应式设计,重点不是让每个控件在所有尺寸中同时出现,而是决定每个尺寸下哪些信息常驻、哪些按需展开、哪些必须滚动,以及画布如何拿到稳定的剩余空间。安全区解决物理遮挡,窗口阈值解决工作台形态,面板约束解决内容溢出,画布边界和局部重绘则保证旋转之后仍然好用。做到这些,手机上的紧凑编辑和大屏上的并行工作才是同一个产品的两种可靠形态。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐