HarmonyOS鸿蒙Next中Scroll组件和textArea组件嵌套滚动解决方案

HarmonyOS鸿蒙Next中Scroll组件和textArea组件嵌套滚动解决方案 需求:textArea文本内容垂直滚动到顶/底时,需要接着滚动父组件Scroll

背景:目前API版本,scroller控制器无法应用于textArea,且textArea组件中没有nestedScroll属性可以像Scroll等组件一样设置

.nestedScroll({
  scrollForward: NestedScrollMode.SELF_FIRST,
  scrollBackward:NestedScrollMode.PARENT_FIRST
})

环境要求:compatibleSdkVersion 12+

解决方案:

1)判定textArea组件是否滚动到顶/底

是否滚动到顶,可根据

.onContentScroll((_totalOffsetX: number, totalOffsetY: number) => {})

中的totalOffsetY偏移量 > 0,判定滚动到顶

是否滚动到底,可根据

文本总高度 - totalOffsetY偏移量 <= textArea组件实际渲染高度,判定滚动到底

其中文本总高度可利用

this.getUIContext().getMeasureUtils().measureTextSize({
  textContent: textContent,
  fontSize: fontSize,
  // constraintWidth单位vp
  constraintWidth: constraintWidth,
  lineHeight: ...,
  ...
}).height

来计算,constraintWidth参数可从textArea组件实际渲染宽度获取

2)当textArea组件滚动到顶/底时,将touch事件的move动作的移动距离,传递给父组件scoll的scoller控制器,实现scoll与textArea嵌套滚动

代码实现:

import Logger from '../../common/utils/Logger'

@Component
@Entry
struct ScrollPage {
  @State prompt: ResourceStr = ''
  private scroller: Scroller = new Scroller();

  build() {
    Scroll(this.scroller) {
      Column() {
        buildTextArea({
          textValue: this.prompt,
          parentScroller: this.scroller
        })
      }

      // 其他...
    }
  }​​​​​​​
}

@Component
struct buildTextArea {
  @Link textValue: ResourceStr;
  // 获取textArea组件内当前text文本总高度,单位vp
  @State currentTextHeight: Length | undefined = undefined;
  @State textAreaWidth: Length | undefined = undefined;
  @State textAreaHeight: Length | undefined = undefined;
  @State isScrollTop: boolean = false;
  @State isScrollBottom: boolean = false;
  // 记录触摸点相对父组件的y坐标
  @State touchDownY: number = 0
  private uiContext: UIContext = this.getUIContext();
  private fontSize: Resource = $r('app.float.small_text_size');
  private textPadding: Resource = $r('app.float.line_padding_left')
  parentScroller: Scroller = new Scroller();

  constructor(textValue: ResourceStr, parentScroller: Scroller) {
    super();
    this.textValue = textValue
    this.parentScroller = parentScroller
  }

  /**
   * 根据文本内容,字体大小,行高,行宽等计算文本总高度
   * @param textContent
   * @param fontSize
   * @param constraintWidth 单位vp
   * @returns 单位vp
   */
  private getTextHeight(textContent: ResourceStr, fontSize: Resource, constraintWidth: Length): Length | undefined {
    let textHeight = this.uiContext.getMeasureUtils().measureTextSize({
      textContent: textContent,
      fontSize: fontSize,
      // constraintWidth单位vp
      constraintWidth: constraintWidth
    }).height
    return textHeight ? this.uiContext.px2vp(parseFloat(textHeight.toString())) : undefined
  }

  build() {
    TextArea({ text: this.textValue })
      .fontSize(this.fontSize)
      .width('100%')
      .height('100%')
      .padding(this.textPadding)
      .border({ width: 1, color: '#ddd' })
      .onAreaChange((_oldVal: Area, newVal: Area) => {
        // Area单位vp
        if (!this.textAreaHeight) {
          // 减去上padding的8vp
          this.textAreaHeight = parseFloat(newVal.height.toString()) - 8
        }
        if (!this.textAreaWidth) {
          // 减去左右padding的8vp
          this.textAreaWidth = parseFloat(newVal.width.toString()) - 16
          if (!this.currentTextHeight) {
            this.currentTextHeight = this.getTextHeight(this.textValue, this.fontSize, this.textAreaWidth)
          }
        }
      })
      .onChange((value: string) => {
        this.textValue = value
        if (this.textAreaWidth) {
          this.currentTextHeight = this.getTextHeight(this.textValue, this.fontSize, this.textAreaWidth)
        }
      })
      .onContentScroll((_totalOffsetX: number, totalOffsetY: number) => {
        // totalOffsetY单位px
        // 大于padding-top说明已经滚动到顶
        if (totalOffsetY > 8) {
          Logger.warn("textArea scroll to top")
          this.isScrollTop = true
        } else {
          this.isScrollTop = false
        }
        // 文本总高度 - 滚动偏移量 <= textArea组件高度说明已经滚动到底
        if (this.currentTextHeight && this.textAreaHeight &&
          parseFloat(this.currentTextHeight.toString()) + this.uiContext.px2vp(totalOffsetY) <=
          parseFloat(this.textAreaHeight.toString())) {
          Logger.warn("textArea scroll to bottom")
          this.isScrollBottom = true
        } else {
          this.isScrollBottom = false
        }
      })
      .onTouch((event: TouchEvent) => {
        if (this.isScrollTop) {
          if (event.type === TouchType.Down) {
            this.touchDownY = event.touches[0].y
          } else if (event.type === TouchType.Move) {
            if (this.touchDownY - event.touches[0].y < 0) {
              this.parentScroller.scrollBy(0, this.touchDownY - event.touches[0].y)
            }
            this.touchDownY = event.touches[0].y
          }
        } else if (this.isScrollBottom) {
          if (event.type === TouchType.Down) {
            this.touchDownY = event.touches[0].y
          } else if (event.type === TouchType.Move) {
            if (this.touchDownY - event.touches[0].y > 0) {
              this.parentScroller.scrollBy(0, this.touchDownY - event.touches[0].y)
            }
            this.touchDownY = event.touches[0].y
          }
        }
      })
  }
}

注:其中textPadding为8vp,计算中的padding可从this.textPadding获取


更多关于HarmonyOS鸿蒙Next中Scroll组件和textArea组件嵌套滚动解决方案的实战教程也可以访问 https://www.itying.com/category-93-b0.html

4 回复
  1. 判定 TextArea 是否滚动到顶/底

滚动到顶部

通过 onContentScroll 回调中的 totalOffsetY 偏移量判断:

totalOffsetY > 0 // 滚动到顶部

滚动到底部

通过以下公式判断:

文本总高度 - totalOffsetY偏移量 <= TextArea组件实际渲染高度

文本总高度计算方法:

this.getUIContext().getMeasureUtils().measureTextSize({
  textContent: textContent,
  fontSize: fontSize,
  constraintWidth: constraintWidth, // 单位vp
  lineHeight: ...,
  ...
}).height
  1. 实现嵌套滚动

当 TextArea 滚动到边界时,通过 onTouch 事件捕获滑动距离,并传递给父组件 Scroll 的 scroller.scrollBy() 方法,实现联动滚动。


完整代码实现

主页面组件

import Logger from '../../common/utils/Logger'

@Component
@Entry
struct ScrollPage {
  @State prompt: ResourceStr = ''
  private scroller: Scroller = new Scroller();
  build() {
    Scroll(this.scroller) {
      Column() {
        buildTextArea({
          textValue: this.prompt,
          parentScroller: this.scroller
        })
      }
      // 其他组件...
    }
  }
}

TextArea 嵌套滚动组件

@Component
struct buildTextArea {
  @Link textValue: ResourceStr;
  // 获取textArea组件内当前text文本总高度,单位vp
  @State currentTextHeight: Length | undefined = undefined;
  @State textAreaWidth: Length | undefined = undefined;
  @State textAreaHeight: Length | undefined = undefined;
  @State isScrollTop: boolean = false;
  @State isScrollBottom: boolean = false;
  // 记录触摸点相对父组件的y坐标
  @State touchDownY: number = 0
  private uiContext: UIContext = this.getUIContext();
  private fontSize: Resource = $r('app.float.small_text_size');
  private textPadding: Resource = $r('app.float.line_padding_left') // 8vp
  parentScroller: Scroller = new Scroller();
  constructor(textValue: ResourceStr, parentScroller: Scroller) {
    super();
    this.textValue = textValue
    this.parentScroller = parentScroller
  }
  /**
   * 根据文本内容、字体大小、行高、行宽等计算文本总高度
   * @param textContent 文本内容
   * @param fontSize 字体大小
   * @param constraintWidth 约束宽度,单位vp
   * @returns 文本高度,单位vp
   */
  private getTextHeight(textContent: ResourceStr, fontSize: Resource, constraintWidth: Length): Length | undefined
  {
    let textHeight = this.uiContext.getMeasureUtils().measureTextSize({
      textContent: textContent,
      fontSize: fontSize,
      constraintWidth: constraintWidth // 单位vp
    }).height
    return textHeight ? this.uiContext.px2vp(parseFloat(textHeight.toString())) : undefined
  }
  build() {
    TextArea({ text: this.textValue })
      .fontSize(this.fontSize)
      .width('100%')
      .height('100%')
      .padding(this.textPadding)
      .border({ width: 1, color: '#ddd' })
      .onAreaChange((_oldVal: Area, newVal: Area) => {
        // Area单位vp
        if (!this.textAreaHeight) {
          // 减去上下padding的8vp
          this.textAreaHeight = parseFloat(newVal.height.toString()) - 8
        }
        if (!this.textAreaWidth) {
          // 减去左右padding的16vp (左右各8vp)
          this.textAreaWidth = parseFloat(newVal.width.toString()) - 16
          if (!this.currentTextHeight) {
            this.currentTextHeight = this.getTextHeight(this.textValue, this.fontSize, this.textAreaWidth)
          }
        }
      })
      .onChange((value: string) => {
        this.textValue = value
        if (this.textAreaWidth) {
          this.currentTextHeight = this.getTextHeight(this.textValue, this.fontSize, this.textAreaWidth)
        }
      })
      .onContentScroll((_totalOffsetX: number, totalOffsetY: number) => {
        // totalOffsetY单位px
        // 大于padding-top说明已经滚动到顶
        if (totalOffsetY > 8) {
          Logger.warn("textArea scroll to top")
          this.isScrollTop = true
        } else {
          this.isScrollTop = false
        }
        // 文本总高度 - 滚动偏移量 <= textArea组件高度说明已经滚动到底
        if (this.currentTextHeight && this.textAreaHeight &&
          parseFloat(this.currentTextHeight.toString()) + this.uiContext.px2vp(totalOffsetY) <=
          parseFloat(this.textAreaHeight.toString())) {
          Logger.warn("textArea scroll to bottom")
          this.isScrollBottom = true
        } else {
          this.isScrollBottom = false
        }
      })
      .onTouch((event: TouchEvent) => {
        if (this.isScrollTop) {
          // 滚动到顶部,向下滑动时滚动父组件
          if (event.type === TouchType.Down) {
            this.touchDownY = event.touches[0].y
          } else if (event.type === TouchType.Move) {
            if (this.touchDownY - event.touches[0].y < 0) {
              // 向下滑动(下拉)
              this.parentScroller.scrollBy(0, this.touchDownY - event.touches[0].y)
            }
            this.touchDownY = event.touches[0].y
          }
        } else if (this.isScrollBottom) {
          // 滚动到底部,向上滑动时滚动父组件
          if (event.type === TouchType.Down) {
            this.touchDownY = event.touches[0].y
          } else if (event.type === TouchType.Move) {
            if (this.touchDownY - event.touches[0].y > 0) {
              // 向上滑动(上拉)
              this.parentScroller.scrollBy(0, this.touchDownY - event.touches[0].y)
            }
            this.touchDownY = event.touches[0].y
          }
        }
      })
  }
}

关键技术点

  1. 文本高度计算

使用 MeasureUtils.measureTextSize() API 计算文本总高度:

this.getUIContext().getMeasureUtils().measureTextSize({
  textContent: textContent,
  fontSize: fontSize,
  constraintWidth: constraintWidth // TextArea实际宽度 - padding
})
  1. 滚动边界判断

滚动到顶部:

if (totalOffsetY > 8) { // 8为padding-top
  this.isScrollTop = true
}

滚动到底部:

if (文本总高度 + px2vp(totalOffsetY) <= TextArea组件高度) {
  this.isScrollBottom = true
}
  1. 触摸事件处理

通过 onTouch 捕获滑动距离,并使用 scrollBy() 方法滚动父组件:

.onTouch((event: TouchEvent) => {
  if (this.isScrollTop && touchDownY - currentY < 0) {
    // 向下滑动,滚动父组件
    this.parentScroller.scrollBy(0, touchDownY - currentY)
  } else if (this.isScrollBottom && touchDownY - currentY > 0) {
    // 向上滑动,滚动父组件
    this.parentScroller.scrollBy(0, touchDownY - currentY)
  }
})

注意事项

  1. Padding 计算:示例中 textPadding 为 8vp,计算组件实际内容区域时需要减去 padding 值

  2. 单位转换:

    • onContentScroll 的 totalOffsetY 单位为 px
    • onAreaChange 的 Area 单位为 vp
    • 需要使用 px2vp() 进行单位转换
  3. 性能优化:

    • 文本高度计算会触发多次,建议缓存计算结果
    • 仅在 onChange 和初始化时重新计算文本高度
  4. 边界条件:

    • 确保 TextArea 组件尺寸已初始化后再进行滚动判断
    • 判断条件中的阈值(如 8vp)需要根据实际 padding 调整

更多关于HarmonyOS鸿蒙Next中Scroll组件和textArea组件嵌套滚动解决方案的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


加油,

在HarmonyOS Next中,Scroll组件与textArea组件嵌套时会产生滚动冲突。解决方案是使用Scroll组件的nestedScroll属性配置嵌套滚动模式。通过设置前向或双向嵌套滚动,可以协调父子组件的滚动优先级。具体实现时,需在Scroll组件中明确指定nestedScroll的联动方向,并确保textArea组件正确响应滚动事件。该方法能有效解决组件间的滚动冲突问题。

在HarmonyOS Next中实现Scroll与TextArea的嵌套滚动确实需要手动处理,因为TextArea组件目前不支持nestedScroll属性。你的解决方案很完整,核心思路是通过监听TextArea的滚动状态和触摸事件来手动控制父Scroll的滚动。

关键实现点:

  1. 使用onContentScroll监听TextArea的滚动偏移量,通过totalOffsetY判断是否滚动到顶部或底部
  2. 利用getMeasureUtils().measureTextSize计算文本总高度,结合组件实际高度判断滚动边界
  3. 在onTouch事件中,当TextArea到达边界时,通过parentScroller.scrollBy将触摸移动距离传递给父Scroll

需要注意单位转换问题:onContentScroll返回的偏移量是px单位,而measureTextSize和AreaChange使用的是vp单位,需要通过px2vp进行转换。另外,计算时要考虑padding对实际可用区域的影响。

这个方案在API 12+环境下运行良好,有效解决了TextArea与Scroll的嵌套滚动需求。

回到顶部