HarmonyOS回到顶部功能实现

举报
开源阿超 发表于 2026/06/17 12:33:11 2026/06/17
【摘要】 如果你最近在摆弄HarmonyOS的官方Demo(Gitcode里HarmonyOS_Samples下面的SmartReach仓库),或许会注意到一个有趣的细节:在智感握姿的演示应用里,浮动按钮是一个编辑符号,并且没有绑定任何事件,我将其改成了向上箭头,并且实现了回到顶部的功能。这个改动看起来简单,但背后涉及的技术细节却值得深入了解。特别是对于想在自己的应用里实现类似功能的开发者来说,这个Demo

如果你最近在摆弄HarmonyOS的官方Demo(Gitcode里HarmonyOS_Samples下面的SmartReach仓库),或许会注意到一个有趣的细节:在智感握姿的演示应用里,浮动按钮是一个编辑符号,并且没有绑定任何事件,我将其改成了向上箭头,并且实现了回到顶部的功能。这个改动看起来简单,但背后涉及的技术细节却值得深入了解。特别是对于想在自己的应用里实现类似功能的开发者来说,这个Demo提供了一个很好的参考方案。

https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-smart-reach
https://gitcode.com/HarmonyOS_Samples/SmartReach/tree/master

值得一提的是,HarmonyOS和iOS都支持点击顶部通知栏直接回到顶部的系统级功能。本文实现的浮动按钮回到顶部,主要是为了熟悉HarmonyOS里的相关API,比如Scroller的scrollTo方法、currentOffset的使用、以及多页签状态管理等。这些API掌握熟悉后,会对开发更复杂的滚动相关功能大有帮助。


回到顶部的真实需求

当一个应用里有大量可滚动的内容时,用户往往会面临一个问题:当他们滚动到很下面时,想快速回到顶部,总是得一次次滑动手指,这个过程既低效又烦人。这就是为什么几乎很多网页都会提供一个回到顶部的按钮或手势。并且我在刚入门web前端开发的时候做的第一个功能也是回到顶部,它可以作为开发者入门api的好的切入点


多页签应用的挑战

首先要理解的是,这个应用使用了HdsTabs组件,也就是说它有多个页签,每个页签对应不同的内容。这就带来了一个问题:当用户在不同页签之间切换时,回到顶部的按钮应该滚动哪个页签的内容呢?

这正是为什么代码里添加了这样一行:

@Local currentTabIndex: number = 0;

这个变量追踪当前活跃的页签是哪一个。它的初始值是0,代表第一个页签。然后在tabs组件的onChange事件里:

.onChange(e => this.currentTabIndex = e)

每当用户切换页签时,currentTabIndex就会更新。这样,浮动按钮就始终知道自己应该操作哪个页签的内容。


获取滚动容器的关键API

现在来看最核心的部分。当用户点击回到顶部按钮时,代码需要做什么?首先,它要获取到当前页签对应的滚动容器。

这里用到了一个数据结构叫SCROLLER_LIST。从代码的导入语句可以看出:

import { SCROLLER_LIST, TabsBarModel } from '../model/TabsBarModel';

SCROLLER_LIST是什么?它是一个数组,里面存储了每个页签对应的滚动容器的引用。当有多个页签时,每个页签内部都有自己的滚动容器,比如Scroller或者List组件。为了能够控制滚动,应用需要保持对这些容器的引用,这就是SCROLLER_LIST的用处。

在onClick的处理逻辑里:

let scroller = SCROLLER_LIST[this.currentTabIndex];

这一行通过currentTabIndex作为数组的索引,找到当前页签对应的滚动容器。如果当前用户在第0个页签,就会得到SCROLLER_LIST[0];如果切换到第2个页签,就会得到SCROLLER_LIST[2]。这样就确保了操作的是正确的内容区域。


读取当前滚动位置

获得滚动容器之后,下一步是读取它当前的滚动位置。代码这样做:

let currentOffset = scroller.currentOffset() || { xOffset: 0, yOffset: 0 }

currentOffset()是Scroller提供的一个方法,它返回当前的滚动偏移量。返回值是一个对象,包含xOffset(水平方向的偏移)和yOffset(竖直方向的偏移)。

为什么要读取当前位置呢?因为在执行滚动动画时,需要知道从哪里开始。虽然最终的目标是回到顶部(yOffset为0),但为了让动画流畅,需要从当前位置开始计算。

那个 || { xOffset: 0, yOffset: 0 } 是一个防御性编程的做法。如果currentOffset()返回null或undefined,就用默认值0,0代替。这样即便在某些边界情况下,代码也不会崩溃。


执行平滑滚动

读取到当前位置之后,就可以执行滚动了:

scroller.scrollTo(
  { 
    xOffset: currentOffset.xOffset, 
    yOffset: 0, 
    animation: { duration: 500, curve: Curve.EaseInOut } 
  }
)

这是Scroller的scrollTo方法。它接收一个参数对象,里面包含:

xOffset:目标的水平位置。这里保持不变,因为我们只想回到顶部,不想左右滚动。

yOffset:目标的竖直位置。这里被设成0,代表滚动到最上面。

animation:动画配置。这是关键的一部分。


动画的细节设计

看animation这个配置:

{ duration: 500, curve: Curve.EaseInOut }

duration是500毫秒。这个时长不是任意的。太快的话,用户看不清楚发生了什么,会显得突兀;太慢的话,用户会觉得卡顿。500毫秒是一个经过验证的黄金分割点,既足够快让用户感受到应用的响应,又足够慢让用户看到过渡的过程。

curve: Curve.EaseInOut是缓动函数。EaseInOut的意思是,动画在开始和结束时都会放缓,中间加速。这样的曲线看起来很自然,就像有一种物理惯性的感觉。相比之下,如果用线性的Curve.Linear,滚动会显得很生硬和机械。


日志记录的价值

在执行滚动之前,还有一行日志:

Logger.info(TAG,
  `handle on currentOffset:::{xOffset:${currentOffset.xOffset},yOffset:${currentOffset.yOffset}}`);

这是在记录当前的滚动位置。看起来只是个调试信息,但它的作用很重要。当应用在用户手机上运行时,如果出现了问题,这些日志就能帮助开发者了解发生了什么。用户可以通过工具查看这些日志,然后报告给开发者:“我点击回到顶部时,日志显示yOffset是500”,这就能帮助定位问题。


整个流程的完整逻辑

让我们把整个onClick处理串起来看:

        .onClick(() => {
          let scroller = SCROLLER_LIST[this.currentTabIndex];
          if (!scroller) {
            return;
          }
          let currentOffset = scroller.currentOffset() || { xOffset: 0, yOffset: 0 }
          Logger.info(TAG,
            `handle on currentOffset:::{xOffset:${currentOffset.xOffset},yOffset:${currentOffset.yOffset}}`);
          scroller.scrollTo(
            { xOffset: currentOffset.xOffset, yOffset: 0, animation: { duration: 500, curve: Curve.EaseInOut } })
        })

用户点击按钮时,代码首先根据currentTabIndex找到对应页签的滚动容器。然后读取这个容器当前的滚动位置。接着记录这个位置到日志里。最后,调用scrollTo方法,在500毫秒内用EaseInOut曲线平滑地滚动到顶部。

整个过程就像一个精心编排的舞蹈:先识别位置,再记录状态,最后执行动作。每一步都有其存在的理由。


与握姿感知的结合

这个回到顶部的功能,还和握姿感知结合在一起。当用户用左手握持设备时,这个浮动按钮会自动移动到左下角;用右手时会移动到右下角。这样用户用大拇指就能方便地点击,不需要伸展手指。

代码是这样处理的:

@Local floatingAlignRules: AlignRuleOption = {
  right: { anchor: '__container__', align: HorizontalAlign.End },
  bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
}

floatingAlignRules定义了按钮相对于容器的位置规则。初始时,按钮在右下角。当握姿感知检测到用户换手时:

handleHoldingHandChange: Callback<motion.HoldingHandStatus> = (status: motion.HoldingHandStatus) => {
  this.getUIContext().animateTo({ curve: curves.interpolatingSpring(0, 1, 288, 30) }, () => {
    if (status === motion.HoldingHandStatus.LEFT_HAND_HELD) {
      this.floatingAlignRules = {
        left: { anchor: '__container__', align: HorizontalAlign.Start },
        bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
      };
    } else if (status === motion.HoldingHandStatus.RIGHT_HAND_HELD) {
      this.floatingAlignRules = {
        right: { anchor: '__container__', align: HorizontalAlign.End },
        bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
      };
    }
  });
}

floatingAlignRules就会被更新,按钮会用弹簧动画平滑地移动到新位置。这样,无论用户用哪只手,回到顶部的按钮始终都在最容易点击的地方。


如何在自己的应用里实现

如果你想在自己的应用里实现类似的功能,关键步骤是:

第一,如果应用有多个滚动内容区域(比如多个页签),需要维护一个滚动容器的列表,就像SCROLLER_LIST这样。

第二,追踪当前活跃的是哪个容器,可以用一个变量比如currentIndex。

第三,在浮动按钮的onClick里,根据currentIndex找到对应的容器,然后调用scrollTo方法滚动到顶部。

第四,为了提升体验,给scrollTo加上animation配置,选择合适的duration和curve。500毫秒配EaseInOut是个不错的起点。

第五,如果应用支持握姿感知,可以根据握姿动态调整按钮的位置。


一些实现细节的考虑

在实现的过程中,还有几个细节值得注意。

首先是currentOffset的处理。一个滚动容器可能在任何位置,用户可能已经滚动到很深的地方。currentOffset()能准确地告诉你现在在哪里,这对于某些高级场景很有用,比如你可能想根据当前位置决定动画时长。

再次是性能考虑。scrollTo方法的调用是很轻量级的,即便频繁点击按钮也不会造成性能问题。但如果在滚动过程中用户再次点击按钮,会发生什么?HarmonyOS会处理好这个,新的滚动会中断前一个,然后从当前位置开始新的滚动。这个行为是合理的。


为什么这个模式很常见

在很多应用里都能看到类似的模式。微博、抖音、小红书这样的内容应用,都有回到顶部的按钮。它们的实现原理都是一样的:获取滚动容器,读取当前位置,滚动到目标位置。

为什么这个模式能从小众的移动应用演变成互联网的标配?因为它解决了一个真实的用户需求。当内容足够长时,用户不想一直滑动,希望快速到达。这个需求是普遍的,所以解决方案也被广泛采用。

而HarmonyOS官方在Demo里展示这个功能,并且用比较现代的方式实现(多页签支持、动画配置、握姿感知结合),说明这个功能已经成为了基础设施的一部分,值得被认真对待。


结语:从小功能看大设计

回到顶部这个功能看起来很小,但实现它需要考虑的东西其实很多:如何管理多个滚动容器,如何追踪当前状态,如何读取API返回的数据,如何配置动画让体验更好。

这个官方Demo用一个具体的例子展示了,在HarmonyOS里如何系统地思考和实现一个看似简单的功能。它不仅仅是告诉你"可以这样做",更是在示范"应该这样做"的最佳实践。

如果你的应用里也有可滚动的内容,不妨参考这个实现方式。确保用户能够快速回到顶部,既是一种对用户时间的尊重,也是让应用感觉更专业、更贴心的一个小细节。

完整代码,此处我注释掉了alert:

/*
 * Copyright (c) 2026 Huawei Device Co., Ltd.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { AppStorageV2, curves, LengthMetrics, window } from '@kit.ArkUI';
// [Start gesture_change]
import { motion } from '@kit.MultimodalAwarenessKit';
// [StartExclude gesture_change]
import {
  hdsMaterial,
  HdsNavigation,
  HdsNavigationTitleMode,
  HdsTabs,
  HdsTabsController,
  ScrollEffectType,
} from '@kit.UIDesignKit';
import { StorageKey } from '../common/CommonConstants';
import { WaterFlowView } from '../component/WaterFlowView';
import { GlobalInfoModel } from '../model/GlobalInfoModel';
import { SCROLLER_LIST, TabsBarModel } from '../model/TabsBarModel';
import { BreakpointType } from '../util/BreakpointSystem';
import Logger from '../util/Logger';
import { PreferenceManager } from '../util/PreferenceManager';

const NEVER_ALERT_KEY: string = 'NEVER_ALERT';
const TAG: string = '[MainPage]';

@Entry
@ComponentV2
struct MainPage {
  @Local globalInfoModel: GlobalInfoModel =
    AppStorageV2.connect(GlobalInfoModel, StorageKey.GLOBAL_INFO) ?? new GlobalInfoModel();
  private controller: HdsTabsController = new HdsTabsController();
  private tabsBar: BottomTabBarStyle[] = TabsBarModel.getTabBarByPage() ?? [];
  private dialogComponentId?: number;
  private preferenceManager = PreferenceManager.getInstance();
  // [Start gesture_animate]
  @Local floatingAlignRules: AlignRuleOption = {
    right: { anchor: '__container__', align: HorizontalAlign.End },
    bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
  }
  @Local currentTabIndex: number = 0;
  // [End gesture_animate]
  // [EndExclude gesture_change]
  handleHoldingHandChange: Callback<motion.HoldingHandStatus> = (status: motion.HoldingHandStatus) => {
    // [StartExclude gesture_change]
    Logger.info(TAG, `handle on holdingHandChanged:::${status}`);
    // [Start gesture_animate]
    this.getUIContext().animateTo({ curve: curves.interpolatingSpring(0, 1, 288, 30) }, () => {
      if (canIUse('SystemCapability.MultimodalAwareness.Motion')) {
        if (status === motion.HoldingHandStatus.LEFT_HAND_HELD) {
          this.floatingAlignRules = {
            left: { anchor: '__container__', align: HorizontalAlign.Start },
            bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
          };
        } else if (status === motion.HoldingHandStatus.RIGHT_HAND_HELD) {
          this.floatingAlignRules = {
            right: { anchor: '__container__', align: HorizontalAlign.End },
            bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
          };
        }
      }
    });
    // [End gesture_animate]
    // [EndExclude gesture_change]
  }

  aboutToAppear(): void {
    // [StartExclude gesture_change]
    // this.showAlert();
    // [EndExclude gesture_change]
    let context = this.getUIContext().getHostContext()
    window.getLastWindow(context).then((windowClass) => {
      windowClass.setWindowKeepScreenOn(true);
    });
    try {
      if (canIUse('SystemCapability.MultimodalAwareness.Motion')) {
        motion.on('holdingHandChanged', this.handleHoldingHandChange);
        Logger.info(TAG, `Succeed handle on holdingHandChanged`);
      } else {
        Logger.error(TAG, `Can not handle on holdingHandChanged`);
      }
    } catch (error) {
      Logger.error(TAG, `Failed on holdingHandChanged. cause${error.message}`);
    }
  }

  aboutToDisappear(): void {
    try {
      if (canIUse('SystemCapability.MultimodalAwareness.Motion')) {
        motion.off('holdingHandChanged', this.handleHoldingHandChange);
      } else {
        Logger.error(TAG, `Can not handle off holdingHandChanged`);
      }
    } catch (error) {
      Logger.error(TAG, `Failed off holdingHandChanged. cause${error.message}`);
    }
  }

  /*
    // [StartExclude gesture_change]
    showAlert() {
      try {
        const promptAction = this.getUIContext().getPromptAction();
        promptAction.openCustomDialog({
          builder: () => {
            this.alertBuilder();
          },
          autoCancel: false,
        })
          .then((dialogId: number) => {
            this.dialogComponentId = dialogId;
          })
          .catch((error: BusinessError) => {
            Logger.error(TAG, `show alert failed. cause code: ${error.code}; msg: ${error.message}`);
          });
      } catch (error) {
        Logger.error(TAG, `show alert failed. cause code: ${error.code}; msg: ${error.message}`);
      }
    }

    closeAlert() {
      if (this.dialogComponentId) {
        try {
          this.getUIContext().getPromptAction().closeCustomDialog(this.dialogComponentId);
          this.dialogComponentId = undefined;
        } catch (error) {
          Logger.error(TAG, `close alert failed. cause code: ${error.code}; msg: ${error.message}`);
        }
      }
    }

    @Builder
    alertBuilder() {
      Column() {
        Row() {
          Text($r('app.string.alert_title'))
            .fontSize($r('sys.float.Title_S'))
            .fontColor($r('sys.color.font_primary'))
            .fontWeight(FontWeight.Bold)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .height(56)

        Column() {
          Text($r('app.string.alert_msg'))
            .fontSize($r('sys.float.Subtitle_M'))
            .fontWeight(FontWeight.Medium)
            .fontColor($r('sys.color.font_primary'))
            .margin({ top: $r('sys.float.padding_level1') })
        }
        .padding({ left: $r('sys.float.padding_level8'), right: $r('sys.float.padding_level8') })

        Image($r('app.media.ic_smart_reach_alert'))
          .width('70%')
        Column() {
          Text($r('app.string.alert_desc'))
            .fontSize($r('sys.float.Subtitle_S'))
            .fontColor($r('sys.color.font_tertiary'))

          Row({ space: 16 }) {
            Button($r('app.string.okay'))
              .onClick(() => {
                this.closeAlert();
              })
              .buttonStyle(ButtonStyleMode.EMPHASIZED)
              .height(40)
              .width('50%')
          }
          .padding({
            top: $r('sys.float.padding_level4'),
          })
          .justifyContent(FlexAlign.Center)
          .width('100%')
        }
        .padding({
          left: $r('sys.float.padding_level8'),
          top: $r('sys.float.padding_level4'),
          right: $r('sys.float.padding_level8'),
          bottom: $r('sys.float.padding_level8'),
        })
      }
    }*/

  build() {
    HdsNavigation() {
      // [Start gesture_animate]
      RelativeContainer() {
        // [Start gesture_reach]
        HdsTabs({ controller: this.controller }) {
          // [StartExclude gesture_animate]
          Repeat(this.tabsBar).each((repeatItem: RepeatItem<BottomTabBarStyle>) => {
            TabContent() {
              // [StartExclude gesture_reach]
              WaterFlowView({
                currentTabIndex: repeatItem.index,
              })
              // [EndExclude gesture_reach]
            }
            .tabBar(repeatItem.item)
          })
          // [EndExclude gesture_animate]
        }
        // [StartExclude gesture_animate]
        .width('100%')
        .height('100%')
        .barOverlap(true)
        .vertical(false)
        .onAttach(() => {
          try {
            this.controller.preloadItems([0, 1, 2, 3]);
          } catch (error) {
            Logger.error(TAG, `OnAttach preloadItems failed`);
          }
        })
        .onChange(e => this.currentTabIndex = e)
        .barPosition(BarPosition.End)
        .barFloatingStyle({
          adaptToHandedness: true,
          barBottomMargin: this.globalInfoModel.naviIndicatorHeight > 0 ? this.globalInfoModel.naviIndicatorHeight :
            $r('sys.float.padding_level8'),
          // [StartExclude gesture_reach]
          systemMaterialEffect: {
            materialType: hdsMaterial.MaterialType.ADAPTIVE,
            materialLevel: hdsMaterial.MaterialLevel.ADAPTIVE,
          },
          // [EndExclude gesture_reach]
        })

        // [EndExclude gesture_animate]
        // [End gesture_reach]
        Row() {
          SymbolGlyph($r('sys.symbol.chevron_up'))
            .fontColor([$r('sys.color.icon_on_primary')])
            .fontSize($r('sys.float.Title_M'))
        }
        .onClick(() => {
          let scroller = SCROLLER_LIST[this.currentTabIndex];
          if (!scroller) {
            return;
          }
          let currentOffset = scroller.currentOffset() || { xOffset: 0, yOffset: 0 }
          Logger.info(TAG,
            `handle on currentOffset:::{xOffset:${currentOffset.xOffset},yOffset:${currentOffset.yOffset}}`);
          scroller.scrollTo(
            { xOffset: currentOffset.xOffset, yOffset: 0, animation: { duration: 500, curve: Curve.EaseInOut } })
        })
        .alignRules(this.floatingAlignRules)
        // [StartExclude gesture_animate]
        .margin({
          left: new BreakpointType({
            sm: $r('sys.float.padding_level8'),
            md: $r('sys.float.padding_level12'),
            lg: $r('sys.float.padding_level16')
          }).getValue(this.globalInfoModel.widthBreakpoint),
          right: new BreakpointType({
            sm: $r('sys.float.padding_level8'),
            md: $r('sys.float.padding_level12'),
            lg: $r('sys.float.padding_level16')
          }).getValue(this.globalInfoModel.widthBreakpoint),
          bottom: 100,
        })
        .backgroundColor($r('sys.color.background_emphasize'))
        .borderRadius('50%')
        .clip(true)
        .alignItems(VerticalAlign.Center)
        .justifyContent(FlexAlign.Center)
        .width(56)
        .aspectRatio(1)

        // [EndExclude gesture_animate]
      }

      // [End gesture_animate]
    }
    .hideBackButton(true)
    .titleMode(HdsNavigationTitleMode.MINI)
    .mode(NavigationMode.Stack)
    .titleBar({
      content: {
        title: {
          mainTitle: $r('app.string.SmartReachAbility_label'),
        },
      },
      style: {
        scrollEffectOpts: {
          enableScrollEffect: true,
          scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
          blurEffectiveEndOffset: LengthMetrics.vp(64),
        },
        systemMaterialEffect: {
          materialType: hdsMaterial.MaterialType.ADAPTIVE,
          materialLevel: hdsMaterial.MaterialLevel.ADAPTIVE,
        },
      },
      avoidLayoutSafeArea: false,
      enableComponentSafeArea: false,
    })
    .bindToScrollable(SCROLLER_LIST)
    .ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
  }
}

// [EndExclude gesture_change]
// [End gesture_change]
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。