文档管理中心

滚动与滑动组件实现吸顶效果

问题现象

在HarmonyOS中,如何使用不同的滚动与滑动组件,实现标题吸顶效果?

背景知识

吸顶效果是网页开发中的一种常见交互设计,指当用户滚动页面时,某个元素(如导航栏、标题栏、工具栏等)会固定在浏览器窗口的顶部(或其他指定位置),保持始终可见,不会随着页面滚动而消失。在开发过程中,吸顶效果通常用于需要保持关键元素始终可见的场景,以提升用户体验和操作效率。

在HarmonyOS中有多种实现吸顶效果的方案,以及不同的吸顶效果,不同方案及其需要了解的知识如下:

  • Tabs:通过页签进行内容视图切换的容器组件,每个页签对应一个内容视图。
  • nestedScroll:设置前后两个方向的嵌套滚动模式,实现与父组件的滚动联动。
  • List:列表包含一系列相同宽度的列表项。适合连续、多行呈现同类数据,例如图片和文本。
  • sticky:配合ListItemGroup组件使用,设置ListItemGroup中header是否要吸顶或footer是否要吸底。sticky属性可以设置为StickyStyle.Header|StickyStyle.Footer以同时支持header吸顶和footer吸底。

解决方案

本文主要介绍实现普通吸顶效果、单标题滚动嵌套吸顶效果和多标题滚动嵌套吸顶效果的方案。

  • 普通吸顶效果
    • 方案一:通过Tabs组件的tabBar属性实现吸顶效果。可以自定义实现Tabs效果。
      示例代码如下:
      收起
      自动换行
      深色代码主题
      复制
      1. @Entry
      2. @Component
      3. export struct CommonSolutionOne {
      4. subsController: TabsController = new TabsController();
      5. @State arr: string[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15'];
      6. build() {
      7. Tabs({ barPosition: BarPosition.Start, controller: this.subsController }) {
      8. TabContent() {
      9. List({ space: 10 }) {
      10. ForEach(this.arr, (item: number) => {
      11. ListItem() {
      12. Row() {
      13. Text('item' + item)
      14. .fontSize(16)
      15. .height(72)
      16. .fontColor(Color.Black);
      17. }
      18. .width('100%')
      19. .height(72)
      20. .justifyContent(FlexAlign.Center);
      21. }
      22. .borderRadius(15)
      23. .backgroundColor('#F1F3F5');
      24. }, (item: string) => item);
      25. }
      26. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      27. .padding({ left: 10, right: 10 })
      28. .width('100%')
      29. .height('100%')
      30. .scrollBar(BarState.Off);
      31. }
      32. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      33. .tabBar('关注')
      34. .backgroundColor('#ffffff');
      35. TabContent() {
      36. Text('推荐');
      37. }
      38. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      39. .tabBar('推荐')
      40. .backgroundColor('#ffffff');
      41. }
      42. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      43. .backgroundColor('#ffffff');
      44. }
      45. }

      实现效果如下:

    • 方案二:自定义标题的吸顶实现。若要实现Tabs页面切换效果,需要自定义页面切换逻辑。
      参考上文中方案一,示例代码如下:
      收起
      自动换行
      深色代码主题
      复制
      1. @Entry
      2. @Component
      3. export struct CommonSolutionTwo {
      4. subsController: TabsController = new TabsController();
      5. @State arr: string[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15'];
      6. build() {
      7. Column() {
      8. Row() {
      9. // 吸顶标题
      10. Text('自定义占位标题')
      11. .width('100%')
      12. .height(60)
      13. .textAlign(TextAlign.Center)
      14. .backgroundColor('#66666666');
      15. };
      16. List({ space: 10 }) {
      17. ForEach(this.arr, (item: number) => {
      18. ListItem() {
      19. Row() {
      20. Text('item' + item)
      21. .fontSize(16)
      22. .height(72)
      23. .fontColor(Color.Black);
      24. }
      25. .width('100%')
      26. .height(72)
      27. .justifyContent(FlexAlign.Center);
      28. }
      29. .borderRadius(15)
      30. .backgroundColor('#F1F3F5');
      31. }, (item: string) => item);
      32. }
      33. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      34. .padding({ left: 10, right: 10 })
      35. .width('100%')
      36. .scrollBar(BarState.Off)
      37. .layoutWeight(1); // 更换List组件height属性,高度限制以layoutWeight(1)的形式自动填充父组件高度
      38. }
      39. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      40. .width('100%')
      41. .height('100%') // 普通吸顶效果时,滚动组件的父组件可以不设置高度,但是滚动嵌套吸顶时一定要设置高度限制为100%
      42. .backgroundColor('#ffffff');
      43. }
      44. }

      实现效果如下:

  • 单标题滚动嵌套吸顶效果
    • 方案一: 通过nestedScroll属性,实现吸顶效果。由于nestedScroll属性是滚动与滑动组件的通用属性,理论上在滚动组件嵌套的过程中,支持该属性的滚动组件都可以实现吸顶效果。以Tabs组件为例,示例代码如下:
      收起
      自动换行
      深色代码主题
      复制
      1. @Entry
      2. @Component
      3. struct SingleTitleSolutionOne {
      4. subsController: TabsController = new TabsController();
      5. @State arr: string[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15'];
      6. build() {
      7. Scroll() {
      8. Column() {
      9. Column() {
      10. Text('自定义占位标题')
      11. .fontSize(30)
      12. .width('100%')
      13. .height(200)
      14. .backgroundColor('#66666666')
      15. .textAlign(TextAlign.Center);
      16. }
      17. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      18. Tabs({ barPosition: BarPosition.Start, controller: this.subsController }) {
      19. TabContent() {
      20. List({ space: 10 }) {
      21. ForEach(this.arr, (item: number) => {
      22. ListItem() {
      23. Row() {
      24. Text('item' + item)
      25. .fontSize(16)
      26. .height(72)
      27. .fontColor(Color.Black);
      28. }
      29. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      30. .width('100%')
      31. .height(72)
      32. .justifyContent(FlexAlign.Center);
      33. }
      34. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      35. .borderRadius(15)
      36. .backgroundColor('#F1F3F5');
      37. }, (item: string) => item);
      38. }
      39. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      40. .padding({ left: 10, right: 10 })
      41. .width('100%')
      42. .height('100%')
      43. .scrollBar(BarState.Off)
      44. .nestedScroll({
      45. scrollForward: NestedScrollMode.PARENT_FIRST, // 向上滚动PARENT_FIRST:父组件先滚动,父组件滚动到边缘以后自身滚动。
      46. scrollBackward: NestedScrollMode.SELF_FIRST // 向下滚动SELF_FIRST:自身先滚动,自身滚动到边缘以后父组件滚动。
      47. });
      48. }
      49. .clip(false)
      50. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      51. .tabBar('关注')
      52. .backgroundColor('#ffffff');
      53. TabContent() {
      54. Text('推荐');
      55. }
      56. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      57. .tabBar('推荐')
      58. .backgroundColor('#ffffff');
      59. }
      60. .clip(false)
      61. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      62. .backgroundColor('#ffffff');
      63. }
      64. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      65. .width('100%');
      66. }
      67. .clip(false)
      68. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      69. .edgeEffect(EdgeEffect.Spring)
      70. .friction(0.6)
      71. .backgroundColor('#ffffff')
      72. .scrollBar(BarState.Off)
      73. .width('100%')
      74. .height('100%');
      75. }
      76. }

      效果预览:

      说明

      自定义标题的实现方式,此处主要讲解滚动组件嵌套实现方式。若是想要以自定义标题实现滚动嵌套吸顶效果,滚动组件的父组件一定要设置高度限制为100%,若不设置高度限制,父组件自适应高度会导致吸顶失效,可详见下文中“多标题滚动嵌套吸顶效果”的方案一,其内第一个标题为自定义标题,及其父组件高度注释。

    • 方案二: 由于Waterflow布局本身不支持吸顶,如需使用Waterflow布局实现吸顶效果,可以在滚动组件上通过Stack叠加一个组件实现,示例代码如下:
      收起
      自动换行
      深色代码主题
      复制
      1. // 瀑布流布局项数据模型(用于描述每个子项的布局属性)
      2. export class WaterFlowItemModel {
      3. fullWidth: boolean = false;
      4. stickyTop: number | undefined = undefined;
      5. height: number = 0;
      6. width: number = 0;
      7. bgColor: string = '#f0f0f0';
      8. }
      9. // 瀑布流数据源实现类
      10. export class WaterFlowDataSource implements IDataSource {
      11. private dataArray: WaterFlowItemModel[] = [];
      12. private listeners: DataChangeListener[] = [];
      13. constructor() {
      14. this.dataArray = [];
      15. // 生成10个瀑布流测试数据项
      16. for (let i = 0; i < 10; i++) {
      17. const model = new WaterFlowItemModel();
      18. model.fullWidth = i % 5 === 4;
      19. model.height = 120;
      20. model.bgColor = '#f0f0f0';
      21. this.dataArray.push(model);
      22. }
      23. }
      24. // 获取数据总量
      25. totalCount(): number {
      26. return this.dataArray.length;
      27. }
      28. // 根据索引获取单个数据项
      29. getData(index: number) {
      30. return this.dataArray[index];
      31. }
      32. // 注册数据变更监听器
      33. registerDataChangeListener(listener: DataChangeListener) {
      34. if (this.listeners.indexOf(listener) < 0) {
      35. this.listeners.push(listener);
      36. }
      37. }
      38. // 移除已注册的数据变更监听器
      39. unregisterDataChangeListener(listener: DataChangeListener) {
      40. const pos = this.listeners.indexOf(listener);
      41. if (pos >= 0) {
      42. this.listeners.splice(pos, 1);
      43. }
      44. }
      45. }
      46. @Entry
      47. @Component
      48. struct SingleTitleSolutionTwo {
      49. scroller: Scroller = new Scroller();
      50. datasource: WaterFlowDataSource = new WaterFlowDataSource();
      51. @State scrollOffset: number = 0;
      52. build() {
      53. Stack({ alignContent: Alignment.TopStart }) {
      54. WaterFlow({ scroller: this.scroller }) {
      55. LazyForEach(this.datasource, (item: WaterFlowItemModel, index) => {
      56. FlowItem() {
      57. // 当索引不为1,展示当前索引值
      58. if (index !== 1) {
      59. Text(`${index}`);
      60. }
      61. }
      62. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      63. .margin({ right:20,bottom:12,left:19 })
      64. .borderRadius(15)
      65. .onClick(() => {
      66. console.info('onClick ---- ', index);
      67. })
      68. .width('90%')
      69. .height(item.height)
      70. // .backgroundColor(index === 1 ? '#00000000' : Color.White);
      71. .backgroundColor('#F1F3F5')
      72. }, (key: string) => key);
      73. }
      74. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      75. .edgeEffect(EdgeEffect.Spring)
      76. .onWillScroll((offset: number) => {
      77. this.scrollOffset = this.scroller.currentOffset().yOffset + offset;
      78. })
      79. .width('100%')
      80. .height('100%')
      81. .backgroundColor('#ffffff');
      82. // 在索引为1的WaterFlowItem上方叠加一个组件
      83. Stack() {
      84. Text('自定义占位标题')
      85. .width('100%')
      86. .height(this.datasource.getData(1).height)
      87. .fontColor(Color.White)
      88. .backgroundColor('#66666666')
      89. .fontSize(17)
      90. .textAlign(TextAlign.Center);
      91. }
      92. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      93. .hitTestBehavior(HitTestMode.Transparent)
      94. .position({ x: 0, y: this.scrollOffset >= 120 ? 0 : 120 - this.scrollOffset });
      95. }
      96. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      97. .width('100%')
      98. .height('100%')
      99. .clip(true);
      100. }
      101. }

      实现效果如下:

  • 多标题滚动嵌套吸顶效果
    • 方案一:通过nestedScroll属性对滚动组件多次滚动嵌套,参考单标题滚动嵌套吸顶效果,示例代码如下:
      收起
      自动换行
      深色代码主题
      复制
      1. @Entry
      2. @Component
      3. struct MultipleTitleSolutionOne {
      4. subsController: TabsController = new TabsController();
      5. @State arr: string[] = ['1', '2', '3'];
      6. @State arr1: string[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15'];
      7. build() {
      8. Scroll() {
      9. Column() {
      10. Column() {
      11. Text('自定义占位标题')
      12. .fontSize(30)
      13. .width('100%')
      14. .height(200)
      15. .backgroundColor('#66666666')
      16. .textAlign(TextAlign.Center);
      17. };
      18. Column() {
      19. Row() {
      20. Text('自定义占位标题') // 自定义实现标题吸顶,依旧采用Tabs组件也适用
      21. .fontSize(16)
      22. .height(60)
      23. .width('100%')
      24. .textAlign(TextAlign.Center);
      25. }.backgroundColor('#6dbab8b8')
      26. Scroll() {
      27. Column() {
      28. ForEach(this.arr, (item: number) => {
      29. Row() {
      30. Text('item' + item)
      31. .fontSize(16)
      32. .height(72)
      33. .fontColor(Color.Black);
      34. }
      35. .width('100%')
      36. .height(72)
      37. .borderRadius(15)
      38. .margin({
      39. bottom: 10
      40. })
      41. .justifyContent(FlexAlign.Center);
      42. }, (item: string) => item);
      43. Tabs({ barPosition: BarPosition.Start, controller: this.subsController }) {
      44. TabContent() {
      45. List({ space: 10 }) {
      46. ForEach(this.arr1, (item: number) => {
      47. ListItem() {
      48. Row() {
      49. Text('item' + item)
      50. .fontSize(16)
      51. .height(72)
      52. .fontColor(Color.Black);
      53. }
      54. .width('100%')
      55. .height(72)
      56. .justifyContent(FlexAlign.Center);
      57. }
      58. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      59. .borderRadius(15)
      60. .backgroundColor(Color.White);
      61. }, (item: string) => item);
      62. }
      63. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      64. .padding({ left: 10, right: 10 })
      65. .width('100%')
      66. .height('100%')
      67. .scrollBar(BarState.Off)
      68. .nestedScroll({
      69. scrollForward: NestedScrollMode.PARENT_FIRST, // 向上滚动PARENT_FIRST:父组件先滚动,父组件滚动到边缘以后自身滚动。
      70. scrollBackward: NestedScrollMode.SELF_FIRST // 向下滚动SELF_FIRST:自身先滚动,自身滚动到边缘以后父组件滚动。
      71. });
      72. }
      73. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      74. .tabBar('关注')
      75. .backgroundColor('#F1F3F5');
      76. TabContent() {
      77. Text('推荐');
      78. }
      79. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      80. .tabBar('推荐')
      81. .backgroundColor('#F1F3F5');
      82. }
      83. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      84. .backgroundColor('#6dbab8b8');
      85. }
      86. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      87. .backgroundColor(Color.White)
      88. .width('100%');
      89. }
      90. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      91. .width('100%')
      92. .layoutWeight(1)
      93. .scrollBar(BarState.Off)
      94. .nestedScroll({
      95. scrollForward: NestedScrollMode.PARENT_FIRST, // 向上滚动PARENT_FIRST:父组件先滚动,父组件滚动到边缘以后自身滚动。
      96. scrollBackward: NestedScrollMode.SELF_FIRST // 向下滚动SELF_FIRST:自身先滚动,自身滚动到边缘以后父组件滚动。
      97. });
      98. }
      99. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      100. .height('100%') // 由于是自定义的标题,所以容纳标题和列表的父组件一定要设置高度为100%
      101. .backgroundColor('#6dbab8b8');
      102. }
      103. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      104. .width('100%');
      105. }
      106. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      107. .edgeEffect(EdgeEffect.Spring)
      108. .friction(0.6)
      109. .backgroundColor(Color.White)
      110. .scrollBar(BarState.Off)
      111. .width('100%')
      112. .height('100%');
      113. }
      114. }

      实现效果如下:

    • 方案二:通过List组件的sticky属性,实现吸顶效果。

      常见场景一:header吸顶和footer吸底,该方案仅适用于List组件,具体实施方案参考官方示例:吸顶/吸底

      常见场景二:多标题多tab菜单吸顶时,实现滑动时自动切换tab栏。通过onDidScroll方法,监听滚动事件,根据滚动偏移量动态计算当前处于哪个区域,再根据当前区块切换标题内容。示例代码如下:

      收起
      自动换行
      深色代码主题
      复制
      1. @Entry
      2. @Component
      3. struct MultipleTitleSolutionOnePlus {
      4. @State data1: string[] = ['标题1', '标题2', '标题3', '标题4'];
      5. @State data2: string[] = ['1', '2', '3', '4'];
      6. @State selectIndex1: number = 0;
      7. private listController: ListScroller = new ListScroller();
      8. @Builder
      9. topBuilder1() {
      10. Row() {
      11. ForEach(this.data1, (item: string, index: number) => {
      12. Text(item)
      13. .fontColor(index == this.selectIndex1 ? Color.Blue : '#000')
      14. .fontSize(index == this.selectIndex1 ? 20 : 16)
      15. .fontWeight(index == this.selectIndex1 ? 500 : 400)
      16. .onClick(() => {
      17. this.listController.scrollToItemInGroup(0, index, true);
      18. });
      19. });
      20. }
      21. .backgroundColor(0xAABBCC)
      22. .height(60)
      23. .width('100%')
      24. .justifyContent(FlexAlign.SpaceAround);
      25. }
      26. @Builder
      27. topBuilder2() {
      28. Row() {
      29. Text('第二组吸顶')
      30. .padding(5)
      31. .textAlign(TextAlign.Center);
      32. }
      33. .backgroundColor(0xAABBCC)
      34. .justifyContent(FlexAlign.SpaceAround)
      35. .height(60)
      36. .width('100%');
      37. }
      38. build() {
      39. Column() {
      40. List({ space: 10, scroller: this.listController }) {
      41. ListItemGroup({ header: this.topBuilder1, space: 10 }) {
      42. ForEach(this.data1, (item: string) => {
      43. ListItem() {
      44. Text(item)
      45. .width('90%')
      46. .height(200)
      47. .backgroundColor('#f1f3f5')
      48. .borderRadius(10)
      49. .textAlign(TextAlign.Center);
      50. };
      51. });
      52. };
      53. ListItemGroup() {
      54. ListItem() {
      55. Text('其他内容')
      56. .width('90%')
      57. .height(400)
      58. .backgroundColor('#f1f3f5')
      59. .borderRadius(10)
      60. .fontSize(16)
      61. .textAlign(TextAlign.Center);
      62. };
      63. };
      64. ListItemGroup({ header: this.topBuilder2, space: 10 }) {
      65. ForEach(this.data2, (item: string) => {
      66. ListItem() {
      67. Text(item)
      68. .width('90%')
      69. .height(200)
      70. .backgroundColor('#f1f3f5')
      71. .borderRadius(10)
      72. .textAlign(TextAlign.Center);
      73. };
      74. });
      75. };
      76. }
      77. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
      78. .onDidScroll(() => {
      79. let currentOffset = this.listController.currentOffset().yOffset;
      80. this.selectIndex1 = Math.floor(currentOffset / 200);
      81. })
      82. .scrollBar(BarState.Off)
      83. .sticky(StickyStyle.Header)
      84. .alignListItem(ListItemAlign.Center);
      85. };
      86. }
      87. }

      实现效果如下:

      • 二级标题滚动嵌套吸顶效果

        第一层吸顶用Scroll嵌套List实现,第二层是利用ListItemGroup的sticky属性实现,示例代码如下:

        收起
        自动换行
        深色代码主题
        复制
        1. @Entry
        2. @Component
        3. struct MultipleTitleSolutionTwo {
        4. private timeTable: TimeTable[] = [
        5. {
        6. title: 'Header2-0',
        7. projects: ['内容1']
        8. },
        9. {
        10. title: 'Header2-1',
        11. projects: ['内容2']
        12. },
        13. {
        14. title: 'Header2-2',
        15. projects: ['内容3']
        16. }
        17. ];
        18. @Builder
        19. itemHead(text: string, index: number) {
        20. if (index === 0) {
        21. Text().height(0);
        22. } else {
        23. Text(text)
        24. .width('100%')
        25. .textAlign(TextAlign.Center)
        26. .fontColor(Color.Black)
        27. .height(index === 1 ? 100 : 200)
        28. .backgroundColor('#d1d1d6');
        29. }
        30. }
        31. build() {
        32. Scroll() {
        33. Column() {
        34. Text('自定义占位标题')
        35. .width('100%')
        36. .height(200)
        37. .backgroundColor('#66666666')
        38. .textAlign(TextAlign.Center);
        39. Column() {
        40. // 第一层吸顶效果主要是通过设置nestedScroll属性以及父组件设置高度100%实现
        41. Text('Header1')
        42. .width('100%')
        43. .height(100)
        44. .backgroundColor('#4d666666')
        45. .textAlign(TextAlign.Center)
        46. .fontColor(Color.Black);
        47. List() {
        48. ForEach(this.timeTable, (item: TimeTable, index) => {
        49. ListItemGroup({ header: this.itemHead(item.title, index) }) {
        50. ForEach(item.projects, (project: string) => {
        51. ListItem() {
        52. Text(project)
        53. .width('100%')
        54. .height(800)
        55. .fontSize(20)
        56. .textAlign(TextAlign.Center)
        57. .backgroundColor('#FFFFFF');
        58. }
        59. }, (item: string) => item);
        60. }.clip(false);
        61. });
        62. }
        63. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
        64. .sticky(StickyStyle.Header)
        65. .scrollBar(BarState.Off)
        66. .width('100%')
        67. .height('calc(100% - 100vp)')
        68. .backgroundColor('#ffffff')
        69. .edgeEffect(EdgeEffect.Spring)
        70. .nestedScroll({
        71. scrollForward: NestedScrollMode.PARENT_FIRST,
        72. scrollBackward: NestedScrollMode.SELF_FIRST
        73. });
        74. }.clip(false).expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]).height('100%')
        75. }.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
        76. }
        77. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
        78. .clip(false)
        79. .edgeEffect(EdgeEffect.Spring)
        80. .friction(0.6)
        81. .backgroundColor(Color.White)
        82. .scrollBar(BarState.Off)
        83. .width('100%')
        84. .height('100%');
        85. }
        86. }
        87. interface TimeTable {
        88. title: string;
        89. projects: string[];
        90. }

        实现效果如下:

      • 动态控制二级标题滚动嵌套吸顶效果

        在List组件onDidScroll方法里判断索引为2的组件滚动偏移量,动态调整Header1的position属性和List组件的contentStartOffset属性,示例代码如下:

        收起
        自动换行
        深色代码主题
        复制
        1. @Entry
        2. @Component
        3. struct MultipleTitleSolutionTwoDynamic {
        4. @State header1Position: number = 0;
        5. @State listStartOffset: number = 100;
        6. private scroller = new Scroller();
        7. private timeTable: TimeTableDynamic[] = [
        8. {
        9. title: 'Header2-0',
        10. projects: ['内容1']
        11. },
        12. {
        13. title: 'Header2-1',
        14. projects: ['内容2']
        15. },
        16. {
        17. title: 'Header2-2',
        18. projects: ['内容3']
        19. }
        20. ];
        21. @Builder
        22. itemHead(text: string, index: number) {
        23. if (index === 0) {
        24. Text().height(0);
        25. } else {
        26. Text(text)
        27. .width('100%')
        28. .textAlign(TextAlign.Center)
        29. .fontColor(Color.Black)
        30. .height(index === 1 ? 100 : 200)
        31. .backgroundColor('#d1d1d6');
        32. }
        33. }
        34. build() {
        35. Scroll() {
        36. Column() {
        37. Text('二级标题滚动嵌套吸顶')
        38. .width('100%')
        39. .height(200)
        40. .backgroundColor('#66666666')
        41. .textAlign(TextAlign.Center);
        42. Column() {
        43. // 第一层吸顶效果主要是通过设置nestedScroll属性以及父组件设置高度100%实现
        44. Text('Header1')
        45. .width('100%')
        46. .height(100)
        47. .backgroundColor('#4d666666')
        48. .textAlign(TextAlign.Center)
        49. .fontColor(Color.Black)
        50. .position({ y: this.header1Position })
        51. .zIndex(10);
        52. List({ scroller: this.scroller }) {
        53. ForEach(this.timeTable, (item: TimeTableDynamic, index) => {
        54. ListItemGroup({ header: this.itemHead(item.title, index) }) {
        55. ForEach(item.projects, (project: string) => {
        56. ListItem() {
        57. Text(project)
        58. .width('100%')
        59. .height(800)
        60. .fontSize(20)
        61. .textAlign(TextAlign.Center)
        62. .backgroundColor('#ffffff');
        63. }.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]);
        64. }, (item: string) => item);
        65. };
        66. });
        67. }
        68. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
        69. .sticky(StickyStyle.Header)
        70. .scrollBar(BarState.Off)
        71. .width('100%')
        72. .height('100%')
        73. .backgroundColor('#ffffff')
        74. .edgeEffect(EdgeEffect.Spring)
        75. .contentStartOffset(this.listStartOffset)
        76. .nestedScroll({
        77. scrollForward: NestedScrollMode.PARENT_FIRST,
        78. scrollBackward: NestedScrollMode.SELF_FIRST
        79. })
        80. .onDidScroll(() => {
        81. let secondRect = this.scroller.getItemRect(2);
        82. if (secondRect.x === 0 && secondRect.y === 0 && secondRect.width === 0 && secondRect.height === 0) {
        83. console.info(`索引为${2}的组件不在屏幕上`);
        84. return;
        85. }
        86. this.header1Position = Math.min(0, secondRect.y - 200);
        87. this.listStartOffset = Math.max(0, 100 + this.header1Position);
        88. });
        89. }.height('100%');
        90. };
        91. }.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
        92. .clip(false)
        93. .edgeEffect(EdgeEffect.Spring)
        94. .friction(0.6)
        95. .backgroundColor(Color.White)
        96. .scrollBar(BarState.Off)
        97. .width('100%')
        98. .height('100%');
        99. }
        100. }
        101. interface TimeTableDynamic {
        102. title: string;
        103. projects: string[];
        104. }

        实现效果如下:

常见FAQ

Q:还有其他组件能实现吸顶效果吗?

A:可以通过Stack组件或者zIndex属性,这类层叠布局实现组件吸顶。但这类层叠方式,该场景下并不常见,该方式更多用于网页中的客服按钮/回到页面顶部等场景。

Q:如何实现滑动时吸顶标题组件颜色渐变的效果?

A:可以通过onDidScroll等监听滑动的事件,监听组件的偏移量,从而控制吸顶的标题组件改变透明度、背景颜色等状态。

总结

以上方案差异及适用场景如下:

展开

分类

普通吸顶效果

单标题滚动嵌套吸顶效果

多标题滚动嵌套吸顶效果

实现方式

Tabs或自定义标题栏

nestedScroll属性滚动嵌套Tabs或自定义标题栏

nestedScroll属性滚动嵌套Tabs或自定义标题栏/List组件的sticky属性

自定义吸顶标题时易错问题

自定义标题栏时Scroll等滚动组件没有设置高度限制导致滚动组件超出安全区。其最大自适应高度为屏幕的100%高度,可以设置height属性或者layoutWeight(1)属性限制高度。

滚动组件的父组件一定要设置高度限制为100%,若不设置高度限制,父组件自适应高度会导致吸顶失效,因为滚动组件的最大自适应高度为屏幕高度,当滚动组件子组件高度之和大于屏幕的100%时,滚动组件高度自适应100%高度,会导致标题超出屏幕范围,导致吸顶失效。

当采用nestedScroll属性滚动嵌套实现时,与“单标题滚动嵌套吸顶效果”需要注意相似问题。

适用场景

适用于所有的带标题的软件场景

微信朋友圈等类似场景

电商软件主页场景

在 FAQ 中进行搜索
请输入您想要搜索的关键词