前言

悬浮面板和普通材质卡片相比,麻烦的地方在于它会同时处理好几种变化。

手指按下时,组件需要有反馈;拖动标题区域时,面板要跟着手势移动;点开以后,面板还要展开更多操作;底层内容开始滚动时,它又不能一直挡在阅读区域上方。代码写到这里,interactivelightEffectPanGesturetranslateopacityscaleanimateTo 往往会同时出现。

我自己在组合这类效果时,最容易出现的问题就是动画加得太多。材质已经提供了按压反馈,页面又补一层缩放;拖动位置已经跟着手指更新,外面再套一层显式动画。单独看每段代码都说得通,实际操作后却容易出现跟手发黏、缩放幅度过大,或者面板停下来以后还有动画继续追位置。

后来我更愿意先把这些变化分清楚。按压形变和触点光感交给沉浸材质;拖动位置交给手势;展开收起和滚动显隐由页面状态控制。这样每一种变化都有自己的来源,出了问题也更容易找到对应位置。

这次实验页会做一块可以拖动的动态浮动面板。收起时只显示标题和展开按钮,展开以后增加三个操作项;拖动标题区域可以改变位置;列表向上滚动时,面板会暂时移出页面,反向滚动后再回来。

目前我的测试设备还没有 HarmonyOS 7 实机测试权限,因此当前先使用模拟器检查手势、状态切换和基础动画。材质按压反馈、触点光感和最终操作手感仍建议在具备权限的真机上继续确认。

一、先给每一种动态效果分工

面板开始移动之前,先把谁负责什么确定下来。

ImmersiveMaterialinteractive 可以开启材质的交互形变,lightEffect 用于触摸时的动态光感反馈。这两项都属于材质本身的能力,从 API 26.0.0 开始提供。

当前面板准备两份材质:

private readonly thinMaterial: uiMaterial.Material =
  new uiMaterial.ImmersiveMaterial({
    style: uiMaterial.ImmersiveStyle.THIN,
    interactive: true,
    lightEffect: {
      color: Color.White
    }
  });

private readonly regularMaterial: uiMaterial.Material =
  new uiMaterial.ImmersiveMaterial({
    style: uiMaterial.ImmersiveStyle.REGULAR,
    interactive: true,
    lightEffect: {
      color: Color.White
    }
  });

收起状态使用 THIN,展开以后使用 REGULAR

这里我不会再给按下事件补一套额外的 scale。系统材质已经负责按压阶段的视觉反馈,再增加一套自定义按压缩放,很容易让两种变化叠加。

自定义动画只负责面板自身的状态:

当前变化 页面负责的属性
手指拖动面板 translate
展开和收起 内容高度、opacitytranslate
页面滚动时显隐 translateopacityscale
材质状态 THINREGULAR

因此,这块面板实际可以拆成四条很直观的操作关系:

按下卡片
→ interactive + lightEffect

拖动标题区域
→ PanGesture + translate

点击展开或收起
→ 状态变化 + animateTo

页面上下滚动
→ onDidScroll + 显示状态

PanGesture 可以识别平移手势,并允许设置方向和触发距离;当前官方文档中,非手写笔输入源的默认触发距离为 5vp,因此示例也直接使用 5vp。

这一点和拖动手感关系很直接。识别距离设置得过大以后,手指已经移动了一段距离,面板才开始响应,操作就会显得迟。

另一方面,拖动过程本身是连续输入。我不希望每一次位置更新都重新启动一段动画,所以拖动阶段只计算坐标。

把这些职责确定以后,后面真正需要处理的就只剩三个动作:拖动、展开、滚动。

二、把拖动、展开和滚动串进同一块面板

页面底层是一张普通 List,悬浮面板放在 Stack 上层。

我比较喜欢这种结构,因为浮动面板和内容区彼此独立。列表照常滚动,面板只处理自己的位置和状态。

整个面板主要有三种状态:

收起
展开
滚动隐藏

拖动位置则单独保存,不会因为展开或滚动显隐而丢掉。

拖动时直接更新位置

当前示例使用 PanGesture

手势开始时,先记住面板现在的位置:

private dragStartX: number = 0;
private dragStartY: number = 0;

@State private panelOffsetX: number = 0;
@State private panelOffsetY: number = 0;

随后再根据手势偏移计算新坐标:

PanGesture({
  direction: PanDirection.All,
  distance: 5
})
  .onActionStart(() => {
    this.dragStartX = this.panelOffsetX;
    this.dragStartY = this.panelOffsetY;
  })
  .onActionUpdate((event: PanGestureEvent) => {
    this.panelOffsetX = this.clamp(
      this.dragStartX + event.offsetX,
      -110,
      110
    );

    this.panelOffsetY = this.clamp(
      this.dragStartY + event.offsetY,
      -260,
      120
    );
  })

PanGesture 支持 AllHorizontalVertical 等方向配置。这里允许所有方向移动,再通过 clamp() 限制可拖动范围,避免把整个面板甩出屏幕。

这个地方我没有使用 animateTo

拖动过程中,面板需要尽量贴着手指。每一帧收到新的坐标以后直接更新 translate,操作会更自然:

.translate({
  x: this.panelOffsetX,
  y: this.panelOffsetY + this.visibilityShiftY
})

还有一个细节我觉得比较重要。

拖动手势只绑定在面板标题区域,没有覆盖整个面板,更没有覆盖底层页面。List 自身已经包含滚动手势,再大范围加入自定义手势时,需要留意组件之间的手势响应关系。官方 List 文档也特别提醒,列表内部已经绑定了用于跟手滚动的手势,自定义手势需要注意冲突处理。

所以当前页面的操作会比较明确:

拖动面板标题
→ 移动悬浮面板

滑动列表区域
→ 滚动页面

点击面板按钮
→ 执行对应操作

大家不需要在同一个区域里猜测这次滑动究竟是拖面板还是滚列表。

展开和收起使用一次状态动画

拖动属于连续操作,展开则完全不同。

点击一次按钮以后,面板会从收起进入展开,或者从展开回到收起。这个变化有明确的起点和终点,所以更适合使用显式动画。

当前会同时修改几个状态:

面板高度
内容透明度
内容纵向位置
当前材质

代码如下:

private togglePanel(): void {
  const nextExpanded: boolean =
    !this.isExpanded;

  this.materialMode =
    nextExpanded ? 1 : 0;

  this.getUIContext().animateTo({
    duration: 260,
    curve: Curve.EaseOut
  }, () => {
    this.isExpanded = nextExpanded;

    this.panelHeight =
      nextExpanded ? 216 : 96;

    this.detailOpacity =
      nextExpanded ? 1 : 0;

    this.detailTranslateY =
      nextExpanded ? 0 : 12;
  });
}

收起时使用 THIN,展开以后选择 REGULAR

这里的材质对象早就已经创建完成。点击按钮只是在两份现成材质之间选择,没有在动画回调中反复 new ImmersiveMaterial()

我觉得这个区别值得保留。

材质配置本身变化频率很低,而动画过程中的尺寸、透明度和位移会持续变化。把材质对象跟高频状态放在一起更新,会让代码越来越难看清楚。

当前面板结构简单,展开时直接调整高度没有太大问题。如果真实项目中的面板内容很多,布局变化已经造成动画掉帧,可以把更多视觉变化移到 scaletranslateopacity 这些图形变换属性上。HarmonyOS 当前的性能检查规则也建议,在布局属性动画出现性能问题时优先使用图形变换属性。

滚动时只在方向改变后切换一次状态

面板能够拖,也能够展开以后,页面还有一个实际问题。

长列表阅读过程中,如果面板一直停在底部,它会持续挡住一部分内容。

所以示例加入一个很轻的滚动显隐逻辑:

页面内容向上移动
→ 面板退出底部

页面反向滚动
→ 面板重新出现

当前 List 使用 onDidScroll。官方文档中,onDidScroll 会返回当前帧的滚动偏移量和滚动状态;内容向上滚动时偏移为正,向下滚动时为负。旧的 onScroll 已经废弃,并推荐使用 onDidScroll

因此方向判断可以写得很简单:

private handleListScroll(
  scrollOffset: number,
  scrollState: ScrollState
): void {
  if (scrollState === ScrollState.Idle) {
    return;
  }

  if (scrollOffset > 4) {
    this.hidePanel();
    return;
  }

  if (scrollOffset < -4) {
    this.showPanel();
  }
}

这里留了 4vp 的小阈值。

细微滚动和回弹过程中会产生一些很小的偏移,如果完全按照正负号切换,面板容易在边界附近频繁显隐。加入一点缓冲以后,操作会稳定一些。

隐藏面板时:

private hidePanel(): void {
  if (!this.panelVisible) {
    return;
  }

  this.panelVisible = false;

  this.getUIContext().animateTo({
    duration: 180,
    curve: Curve.EaseOut
  }, () => {
    this.panelOpacity = 0;
    this.visibilityShiftY = 260;
    this.panelScale = 0.96;
  });
}

重新显示:

private showPanel(): void {
  if (this.panelVisible) {
    return;
  }

  this.panelVisible = true;

  this.getUIContext().animateTo({
    duration: 220,
    curve: Curve.EaseOut
  }, () => {
    this.panelOpacity = 1;
    this.visibilityShiftY = 0;
    this.panelScale = 1;
  });
}

这里真正帮到我的,是 panelVisible 这个状态。

onDidScroll 会在滚动过程中持续回调。面板已经隐藏以后,后续同方向滚动会直接返回,不会每一帧再重新启动隐藏动画。官方文档确认 onDidScroll 会在列表滑动过程中持续提供当前帧偏移和滚动状态。

因此整个过程会变成:

第一次向上滚动
→ 执行隐藏动画

继续向上滚动
→ 保持隐藏

开始向下滚动
→ 执行显示动画

继续向下滚动
→ 保持显示

这个小状态判断比不断调整动画参数更有用。

完成这一部分以后,手势和动画已经能够一起运行。后面更值得关注的是,哪些状态适合持续更新,哪些状态只应该偶尔发生变化。


三、面板动得多以后,代码反而要收一点

动态页面很容易越做越热闹。

已经有拖动,可以再加惯性;已经有展开,可以再改变圆角;已经有滚动显隐,也可以顺便修改模糊、阴影和材质颜色。

我自己更愿意少加几项。

判断一个动画怎么写时,我通常先看这个状态变化得有多频繁。

拖动位置属于高频状态

拖动时,手势会不断提供新的位移。

这种情况下最直接的方式就是更新:

panelOffsetX
panelOffsetY

然后交给:

.translate({
  x: this.panelOffsetX,
  y: this.panelOffsetY
})

页面没有必要在拖动的每一步重新创建材质,也没有必要反复启动 animateTo

HarmonyOS 的性能规则建议,在布局动画出现丢帧时优先使用图形变换属性,scaletranslate 等方式通常比频繁改变宽高更适合作为动画手段。

所以当前页面把高频变化尽量集中在:

translate
opacity
scale

这几个属性上。

展开收起属于低频状态

展开按钮只有在用户点击时才会触发。

这种变化频率低,而且有明确的开始和结束,所以用 animateTo 处理更自然。显式动画本身就是用状态变化描述起点和终点,再由系统完成中间过渡。

当前 Demo 为了真正腾出内容空间,会改变 panelHeight

页面只有几个按钮时,这样处理比较直观。换成复杂工具箱以后,如果高度动画已经影响流畅度,可以把固定容器保留下来,再主要使用内容的 opacitytranslatescale 表现展开状态。

这里没有统一答案,还是要看面板里面到底放了多少内容。

材质对象没有必要跟着动画更新

当前页面只需要:

THIN
REGULAR

两种材质。

所以直接提前创建:

private readonly thinMaterial =
  new uiMaterial.ImmersiveMaterial(...);

private readonly regularMaterial =
  new uiMaterial.ImmersiveMaterial(...);

拖动过程中不会创建新对象。

列表滚动时不会创建新对象。

展开动画的每一帧也不会创建新对象。

状态变化只决定当前使用哪一份材质。

我会把材质对象看成组件比较稳定的视觉配置,而面板位置、透明度和展开状态才属于动态数据。两者分开以后,后面再加按钮或业务逻辑也不会把材质代码搅进手势事件里。

interactive 开启以后,按压缩放可以先省掉

当前两份材质都打开了:

interactive: true

因此,面板本身已经具备系统材质的交互反馈。

页面中的 panelScale 只用于滚动隐藏和重新出现,没有拿它继续制作按压效果。

如果后面再增加:

按下
→ 自定义 scale

同时
→ interactive 系统形变

两种效果会同时作用在组件上。

对于这种面板,我更喜欢先看系统材质反馈够不够。真机上觉得反馈不足时,再决定是否增加自定义动画,这样调节范围会更清楚。

拖动区域尽量小一些

面板可以拖动,底层列表也需要滚动,这两个需求天然会争夺手势。

当前做法是只让标题区域响应 PanGesture

操作 A、操作 B、操作 C 仍然是正常按钮;列表区域也继续保持正常滚动。

官方 PanGesture 支持配置触发方向和距离,而 List 自身已经绑定滚动手势,所以组件内增加自定义拖动时,手势区域和响应关系值得单独检查。(华为开发者)

放回业务页面以后,可以先按这个思路划区域:

页面区域 主要操作
面板标题栏 拖动
展开按钮 展开或收起
面板操作区 点击
面板外列表 滚动

这样比整块浮层都可以拖更容易控制。

这种面板更适合临时高频操作

动态浮动面板并不适合所有页面。

我觉得比较合适的场景有三类。

第一类是内容阅读。图片、文章或视频页面需要一两个随时可用的操作,又不希望工具条长期占据固定空间。

第二类是编辑和创作。图片编辑、文档批注和轻量画布中,工具面板可以拖到当前内容比较空的位置。

第三类是播放和控制。收起时显示核心状态,展开后提供三到五个临时操作。

内容继续增加以后,我会开始考虑其他结构。

当前需求 更合适的形式
一个高频操作 悬浮按钮
两到五个临时操作 动态浮动面板
面板会遮住主要内容 增加拖动
阅读滚动时容易挡内容 增加滚动显隐
已经形成较长操作流程 Sheet 或独立页面

浮动面板的价值还是让操作更顺手。

拖动、展开、材质和光感都只是手段。操作数量已经多到需要不断扩张面板时,页面结构本身可能已经需要重新安排。


总结

这块动态浮动面板做完以后,我觉得最值得保留的习惯,是先把动态效果分工。

材质负责按压形变和触点光感。

PanGesture 负责手指拖到哪里,面板就移动到哪里。

展开、收起以及滚动显隐属于明确的状态变化,可以交给 animateTo

拖动和滚动过程中频繁变化的部分,则尽量集中在 translateopacityscale 上。HarmonyOS 当前也建议在动画性能出现问题时优先考虑图形变换属性,减少布局属性动画带来的额外开销。

材质对象也没有跟着动画过程重复创建。THINREGULAR 提前准备好,页面只根据展开状态选择当前材质。

我更喜欢这样的代码,因为出了问题以后方向比较明确。拖动不跟手,就看 PanGesture 和位移计算;列表滚动时反复闪动,就看显隐状态判断;按压反馈不合适,再回到 interactivelightEffect

当前实验可以在 HarmonyOS 7 模拟器中检查拖动、展开收起、列表滚动和基础动画。材质的交互形变、光感反馈以及最终操作手感仍建议在具备权限的真机上继续确认。

完整代码

Main.ets

/**
 * HarmonyOS 7 沉浸光感深度实战 07
 *
 * 验证环境:
 * HarmonyOS SDK API 26
 * HarmonyOS 7 模拟器
 */

import { uiMaterial } from '@kit.ArkUI';

@Entry
@Component
struct Main {
  /**
   * 页面中的演示数据。
   */
  private readonly items: Array<number> = [
    1, 2, 3, 4, 5, 6,
    7, 8, 9, 10, 11, 12,
    13, 14, 15, 16, 17, 18
  ];

  /**
   * 面板是否展开。
   */
  @State private isExpanded: boolean = false;

  /**
   * 收起状态 96vp。
   * 展开状态 216vp。
   */
  @State private panelHeight: number = 96;

  /**
   * 展开内容使用透明度和纵向位移过渡。
   */
  @State private detailOpacity: number = 0;
  @State private detailTranslateY: number = 12;

  /**
   * 用户拖动产生的位置偏移。
   */
  @State private panelOffsetX: number = 0;
  @State private panelOffsetY: number = 0;

  /**
   * 滚动显隐单独使用一组状态。
   * 这样不会覆盖用户已经拖动出来的位置。
   */
  @State private visibilityShiftY: number = 0;
  @State private panelOpacity: number = 1;
  @State private panelScale: number = 1;
  @State private panelVisible: boolean = true;

  /**
   * 0 表示 THIN。
   * 1 表示 REGULAR。
   */
  @State private materialMode: number = 0;

  /**
   * 每轮 PanGesture 开始时保存当前位置。
   */
  private dragStartX: number = 0;
  private dragStartY: number = 0;

  /**
   * 材质对象提前创建。
   * 拖动和动画过程不会反复创建新材质。
   */
  private readonly thinMaterial: uiMaterial.Material =
    new uiMaterial.ImmersiveMaterial({
      style: uiMaterial.ImmersiveStyle.THIN,
      interactive: true,
      lightEffect: {
        color: Color.White
      }
    });

  private readonly regularMaterial: uiMaterial.Material =
    new uiMaterial.ImmersiveMaterial({
      style: uiMaterial.ImmersiveStyle.REGULAR,
      interactive: true,
      lightEffect: {
        color: Color.White
      }
    });

  /**
   * 限制拖动范围,避免面板完全离开窗口。
   */
  private clamp(
    value: number,
    min: number,
    max: number
  ): number {
    if (value < min) {
      return min;
    }

    if (value > max) {
      return max;
    }

    return value;
  }

  /**
   * 展开或收起面板。
   */
  private togglePanel(): void {
    const nextExpanded: boolean =
      !this.isExpanded;

    /**
     * 状态变化时直接切换已经创建好的材质。
     */
    this.materialMode =
      nextExpanded ? 1 : 0;

    this.getUIContext().animateTo({
      duration: 260,
      curve: Curve.EaseOut
    }, () => {
      this.isExpanded =
        nextExpanded;

      this.panelHeight =
        nextExpanded ? 216 : 96;

      this.detailOpacity =
        nextExpanded ? 1 : 0;

      this.detailTranslateY =
        nextExpanded ? 0 : 12;
    });
  }

  /**
   * 向上滚动内容时隐藏面板。
   *
   * panelVisible 可以避免连续滚动期间
   * 反复启动同一段隐藏动画。
   */
  private hidePanel(): void {
    if (!this.panelVisible) {
      return;
    }

    this.panelVisible = false;

    this.getUIContext().animateTo({
      duration: 180,
      curve: Curve.EaseOut
    }, () => {
      this.panelOpacity = 0;
      this.visibilityShiftY = 260;
      this.panelScale = 0.96;
    });
  }

  /**
   * 反向滚动时重新显示面板。
   */
  private showPanel(): void {
    if (this.panelVisible) {
      return;
    }

    this.panelVisible = true;

    this.getUIContext().animateTo({
      duration: 220,
      curve: Curve.EaseOut
    }, () => {
      this.panelOpacity = 1;
      this.visibilityShiftY = 0;
      this.panelScale = 1;
    });
  }

  /**
   * List 滚动时只判断方向。
   * 4vp 阈值用于过滤较小的滚动变化。
   */
  private handleListScroll(
    scrollOffset: number,
    scrollState: ScrollState
  ): void {
    if (scrollState === ScrollState.Idle) {
      return;
    }

    if (scrollOffset > 4) {
      this.hidePanel();
      return;
    }

    if (scrollOffset < -4) {
      this.showPanel();
    }
  }

  /**
   * 把面板恢复到默认位置。
   */
  private resetPanelPosition(): void {
    this.getUIContext().animateTo({
      duration: 240,
      curve: Curve.EaseOut
    }, () => {
      this.panelOffsetX = 0;
      this.panelOffsetY = 0;
    });
  }

  /**
   * 普通列表内容。
   */
  @Builder
  private contentCard(item: number) {
    Column({ space: 8 }) {
      Text(`内容区域 ${item}`)
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#17203A')
        .width('100%')

      Text(
        '上下滚动页面,观察浮动面板的显示状态。'
      )
        .fontSize(13)
        .fontColor('#68708A')
        .lineHeight(20)
        .width('100%')
    }
    .width('100%')
    .height(112)
    .padding(16)
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Start)
    .backgroundColor(Color.White)
    .borderRadius(20)
  }

  /**
   * 标题区域负责拖动。
   * 面板内部其他按钮继续处理点击操作。
   */
  @Builder
  private draggableHeader() {
    Row({ space: 10 }) {
      Column({ space: 3 }) {
        Text('动态浮动面板')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#17203A')
          .width('100%')

        Text(
          this.materialMode === 0
            ? 'THIN · 拖动这里移动'
            : 'REGULAR · 拖动这里移动'
        )
          .fontSize(11)
          .fontColor('#68708A')
          .width('100%')
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .gesture(
        PanGesture({
          direction: PanDirection.All,
          distance: 5
        })
          .onActionStart(() => {
            this.dragStartX =
              this.panelOffsetX;

            this.dragStartY =
              this.panelOffsetY;
          })
          .onActionUpdate(
            (event: PanGestureEvent) => {
              this.panelOffsetX =
                this.clamp(
                  this.dragStartX
                    + event.offsetX,
                  -110,
                  110
                );

              this.panelOffsetY =
                this.clamp(
                  this.dragStartY
                    + event.offsetY,
                  -260,
                  120
                );
            }
          )
      )

      Button(
        this.isExpanded
          ? '收起'
          : '展开'
      )
        .height(34)
        .fontSize(12)
        .fontColor('#5065E8')
        .backgroundColor('#EEF1FF')
        .onClick(() => {
          this.togglePanel();
        })
    }
    .width('100%')
    .height(52)
    .alignItems(VerticalAlign.Center)
  }

  /**
   * 展开内容始终保留在组件树中。
   * 收起时通过透明度、位移和裁剪隐藏。
   */
  @Builder
  private panelDetails() {
    Column({ space: 10 }) {
      Divider()
        .color('#22FFFFFF')

      Row({ space: 10 }) {
        Button('操作 A')
          .layoutWeight(1)
          .height(36)
          .fontSize(12)

        Button('操作 B')
          .layoutWeight(1)
          .height(36)
          .fontSize(12)

        Button('操作 C')
          .layoutWeight(1)
          .height(36)
          .fontSize(12)
      }
      .width('100%')

      Row({ space: 10 }) {
        Column({ space: 3 }) {
          Text('当前位置')
            .fontSize(11)
            .fontColor('#68708A')

          Text(
            `x=${Math.round(this.panelOffsetX)}  `
              + `y=${Math.round(this.panelOffsetY)}`
          )
            .fontSize(12)
            .fontWeight(FontWeight.Medium)
            .fontColor('#17203A')
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)

        Button('重置位置')
          .height(34)
          .fontSize(12)
          .fontColor('#5065E8')
          .backgroundColor('#EEF1FF')
          .onClick(() => {
            this.resetPanelPosition();
          })
      }
      .width('100%')

      Text(
        '向上滚动时面板退出,反向滚动后重新出现。'
      )
        .fontSize(11)
        .fontColor('#68708A')
        .lineHeight(17)
        .width('100%')
    }
    .width('100%')
    .opacity(this.detailOpacity)
    .translate({
      y: this.detailTranslateY
    })
  }

  /**
   * 完整动态浮动面板。
   */
  @Builder
  private floatingPanel() {
    Column({ space: 8 }) {
      this.draggableHeader()

      this.panelDetails()
    }
    .width(316)
    .height(this.panelHeight)
    .padding({
      left: 16,
      right: 16,
      top: 10,
      bottom: 14
    })
    .backgroundColor(Color.Transparent)
    .borderRadius(28)
    .clip(true)
    .systemMaterial(
      this.materialMode === 0
        ? this.thinMaterial
        : this.regularMaterial
    )
    .translate({
      x: this.panelOffsetX,
      y: this.panelOffsetY
        + this.visibilityShiftY
    })
    .scale({
      x: this.panelScale,
      y: this.panelScale
    })
    .opacity(this.panelOpacity)
    .margin({
      bottom: 28
    })
    .zIndex(10)
  }

  build() {
    Stack({
      alignContent: Alignment.Bottom
    }) {
      Column() {
        Column({ space: 6 }) {
          Text('HarmonyOS 7 沉浸光感')
            .fontSize(28)
            .fontWeight(FontWeight.Bold)
            .fontColor('#11182C')
            .width('100%')

          Text('动态浮动控制面板')
            .fontSize(16)
            .fontColor('#68708A')
            .width('100%')
        }
        .width('100%')
        .padding({
          left: 20,
          right: 20,
          top: 24,
          bottom: 14
        })
        .backgroundColor('#F4F6FB')

        List({
          space: 12
        }) {
          ForEach(
            this.items,
            (item: number) => {
              ListItem() {
                this.contentCard(item)
              }
            },
            (item: number) =>
              item.toString()
          )
        }
        .layoutWeight(1)
        .width('100%')
        .padding({
          left: 20,
          right: 20,
          bottom: 120
        })
        .scrollBar(BarState.Off)
        .onDidScroll(
          (
            scrollOffset: number,
            scrollState: ScrollState
          ) => {
            this.handleListScroll(
              scrollOffset,
              scrollState
            );
          }
        )
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#F4F6FB')

      this.floatingPanel()
    }
    .width('100%')
    .height('100%')
  }
}
Logo

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

更多推荐