HarmonyOS回到顶部功能实现
如果你最近在摆弄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]
- 点赞
- 收藏
- 关注作者
评论(0)