前言

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

手指按下时,组件需要有反馈;拖动标题区域时,面板要跟着手势移动;点开以后,面板还要展开更多操作;底层内容开始滚动时,它又不能一直挡在阅读区域上方。代码写到这里,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、测试、元服务和应用上架分发等。

更多推荐