路曼曼其修远兮,吾将上下而求索。——屈原
https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-smart-reach https://gitcode.com/HarmonyOS_Samples/SmartReach/tree/master
HarmonyOS回到顶部功能实现 如果你最近在摆弄HarmonyOS的官方Demo(gitcode里HarmonyOS_Samples下面的SmartReach仓库),或许会注意到一个有趣的细节:在智感握姿的演示应用里,浮动按钮是一个编辑符号,并且没有绑定任何事件,我将其改成了向上箭头,并且实现了回到顶部的功能。这个改动看起来简单,但背后涉及的技术细节却值得深入了解。特别是对于想在自己的应用里实现类似功能的开发者来说,这个Demo提供了一个很好的参考方案。
值得一提的是,HarmonyOS和iOS都支持点击顶部通知栏直接回到顶部的系统级功能。本文实现的浮动按钮回到顶部,主要是为了熟悉HarmonyOS里的相关API,比如Scroller的scrollTo方法、currentOffset的使用、以及多页签状态管理等。这些API掌握熟悉后,会对开发更复杂的滚动相关功能大有帮助。
回到顶部的真实需求 当一个应用里有大量可滚动的内容时,用户往往会面临一个问题:当他们滚动到很下面时,想快速回到顶部,总是得一次次滑动手指,这个过程既低效又烦人。这就是为什么几乎很多网页都会提供一个回到顶部的按钮或手势。并且我在刚入门web前端开发的时候做的第一个功能也是回到顶部,它可以作为开发者入门api的好的切入点
多页签应用的挑战 首先要理解的是,这个应用使用了HdsTabs组件,也就是说它有多个页签,每个页签对应不同的内容。这就带来了一个问题:当用户在不同页签之间切换时,回到顶部的按钮应该滚动哪个页签的内容呢?
这正是为什么代码里添加了这样一行:
1 @Local currentTabIndex: number = 0;
这个变量追踪当前活跃的页签是哪一个。它的初始值是0,代表第一个页签。然后在tabs组件的onChange事件里:
1 .onChange(e => this.currentTabIndex = e)
每当用户切换页签时,currentTabIndex就会更新。这样,浮动按钮就始终知道自己应该操作哪个页签的内容。
获取滚动容器的关键API 现在来看最核心的部分。当用户点击回到顶部按钮时,代码需要做什么?首先,它要获取到当前页签对应的滚动容器。
这里用到了一个数据结构叫SCROLLER_LIST。从代码的导入语句可以看出:
1 import { SCROLLER_LIST, TabsBarModel } from '../model/TabsBarModel';
SCROLLER_LIST是什么?它是一个数组,里面存储了每个页签对应的滚动容器的引用。当有多个页签时,每个页签内部都有自己的滚动容器,比如Scroller或者List组件。为了能够控制滚动,应用需要保持对这些容器的引用,这就是SCROLLER_LIST的用处。
在onClick的处理逻辑里:
1 let scroller = SCROLLER_LIST[this.currentTabIndex];
这一行通过currentTabIndex作为数组的索引,找到当前页签对应的滚动容器。如果当前用户在第0个页签,就会得到SCROLLER_LIST[0];如果切换到第2个页签,就会得到SCROLLER_LIST[2]。这样就确保了操作的是正确的内容区域。
读取当前滚动位置 获得滚动容器之后,下一步是读取它当前的滚动位置。代码这样做:
1 let currentOffset = scroller.currentOffset() || { xOffset: 0, yOffset: 0 }
currentOffset()是Scroller提供的一个方法,它返回当前的滚动偏移量。返回值是一个对象,包含xOffset(水平方向的偏移)和yOffset(竖直方向的偏移)。
为什么要读取当前位置呢?因为在执行滚动动画时,需要知道从哪里开始。虽然最终的目标是回到顶部(yOffset为0),但为了让动画流畅,需要从当前位置开始计算。
那个 || { xOffset: 0, yOffset: 0 } 是一个防御性编程的做法。如果currentOffset()返回null或undefined,就用默认值0,0代替。这样即便在某些边界情况下,代码也不会崩溃。
执行平滑滚动 读取到当前位置之后,就可以执行滚动了:
1 2 3 4 5 6 7 scroller.scrollTo( { xOffset: currentOffset.xOffset, yOffset: 0, animation: { duration: 500, curve: Curve.EaseInOut } } )
这是Scroller的scrollTo方法。它接收一个参数对象,里面包含:
xOffset:目标的水平位置。这里保持不变,因为我们只想回到顶部,不想左右滚动。
yOffset:目标的竖直位置。这里被设成0,代表滚动到最上面。
animation:动画配置。这是关键的一部分。
动画的细节设计 看animation这个配置:
1 { duration: 500, curve: Curve.EaseInOut }
duration是500毫秒。这个时长不是任意的。太快的话,用户看不清楚发生了什么,会显得突兀;太慢的话,用户会觉得卡顿。500毫秒是一个经过验证的黄金分割点,既足够快让用户感受到应用的响应,又足够慢让用户看到过渡的过程。
curve: Curve.EaseInOut是缓动函数。EaseInOut的意思是,动画在开始和结束时都会放缓,中间加速。这样的曲线看起来很自然,就像有一种物理惯性的感觉。相比之下,如果用线性的Curve.Linear,滚动会显得很生硬和机械。
日志记录的价值 在执行滚动之前,还有一行日志:
1 2 Logger.info(TAG, `handle on currentOffset:::{xOffset:${currentOffset.xOffset},yOffset:${currentOffset.yOffset}}`);
这是在记录当前的滚动位置。看起来只是个调试信息,但它的作用很重要。当应用在用户手机上运行时,如果出现了问题,这些日志就能帮助开发者了解发生了什么。用户可以通过工具查看这些日志,然后报告给开发者:”我点击回到顶部时,日志显示yOffset是500”,这就能帮助定位问题。
整个流程的完整逻辑 让我们把整个onClick处理串起来看:
1 2 3 4 5 6 7 8 9 10 11 .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曲线平滑地滚动到顶部。
整个过程就像一个精心编排的舞蹈:先识别位置,再记录状态,最后执行动作。每一步都有其存在的理由。
与握姿感知的结合 这个回到顶部的功能,还和握姿感知结合在一起。当用户用左手握持设备时,这个浮动按钮会自动移动到左下角;用右手时会移动到右下角。这样用户用大拇指就能方便地点击,不需要伸展手指。
代码是这样处理的:
1 2 3 4 @Local floatingAlignRules: AlignRuleOption = { right: { anchor: '__container__', align: HorizontalAlign.End }, bottom: { anchor: '__container__', align: VerticalAlign.Bottom }, }
floatingAlignRules定义了按钮相对于容器的位置规则。初始时,按钮在右下角。当握姿感知检测到用户换手时:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 import { AppStorageV2 , curves, LengthMetrics , window } from '@kit.ArkUI' ;import { motion } from '@kit.MultimodalAwarenessKit' ;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 (); @Local floatingAlignRules : AlignRuleOption = { right : { anchor : '__container__' , align : HorizontalAlign .End }, bottom : { anchor : '__container__' , align : VerticalAlign .Bottom }, } @Local currentTabIndex : number = 0 ; handleHoldingHandChange : Callback <motion.HoldingHandStatus > = (status : motion.HoldingHandStatus ) => { Logger .info (TAG , `handle on holdingHandChanged:::${status} ` ); 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 }, }; } } }); } aboutToAppear (): void { 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} ` ); } } build ( ) { HdsNavigation () { RelativeContainer () { HdsTabs ({ controller : this .controller }) { Repeat (this .tabsBar ).each ((repeatItem : RepeatItem <BottomTabBarStyle > ) => { TabContent () { WaterFlowView ({ currentTabIndex : repeatItem.index , }) } .tabBar (repeatItem.item ) }) } .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' ), systemMaterialEffect : { materialType : hdsMaterial.MaterialType .ADAPTIVE , materialLevel : hdsMaterial.MaterialLevel .ADAPTIVE , }, }) 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 ) .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 ) } } .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 ]) } }