如何实现文字高亮滚动
==========

#### 问题现象

如何实现文字逐条高亮显示，且当高亮文本超过容器布局一半高度时自动逐行向上滚动到容器底部？

#### 背景知识

*   [RichEditor](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-basic-components-richeditor)：支持图文混排和文本交互式编辑的组件。
*   [measureTextSize](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-apis-uicontext-measureutils#measuretextsize12)：计算指定文本单行布局下的宽度。
*   [scrollBy](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-scroll#scrollby9)：滑动指定距离。
*   [onAreaChange](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-universal-component-area-change-event#onareachange)：组件区域变化时触发该回调。

#### 解决方案

实现思路如下：

1.  利用定时器定时执行高亮数据项匹配，通过updateSpanStyle属性更新索引来变化高亮文字。
2.  利用measureText计算文字宽度，累加高亮过的文字宽度，超过一屏时减去一屏宽度，使得当前行数加1。
3.  利用onAreaChange计算布局容器高度，和布局容器内显示文字的总行数。
4.  利用Scroll滚动组件的scrollBy属性进行滚动，当前行数超过布局一半高度时开始进行滚动。

完整示例参考如下：

import { display } from '@kit.ArkUI';

export class CommonConstants {
  static readonly fontSize16 \= 16;
  static readonly padding16 \= 16;
  static readonly margin40 \= 40;
  static readonly margin36 \= 36;
  static readonly space16 \= 16;
  static readonly FULL\_WIDTH: string \= '100%';
  static readonly FULL\_HEIGHT: string \= '100%';
  static readonly HALF\_HEIGHT: string \= '50%';
}

interface TextBean {
  label: string;
  startTime: number;
  endTime: number;
}

export class Bean {
  static readonly textArray: Array<TextBean\> = \[\];

  static init() {
    for (let i \= 0; i < 100; i++) {
      let text \= \`我是第 ${i} 句话,\`;
      Bean.textArray.push({
        label: text,
        startTime: 1000 \* i,
        endTime: 1000 \* i \+ 1000
      });
    }
  }
}

export class Timer {
  paused: boolean \= false;
  timeout: number \= 0;
  duration: number \= 99000; _// 定时任务总时长_
  currentTime: number \= 0;
  callback: (time: number) \=\> void \= () \=\> {
  };

  constructor(block: (time: number) \=\> void) {
    this.callback \= block;
  }

  setTimer(): void {
    let that \= this;
    this.timeout \= setInterval(() \=\> {
      if (!this.paused) {
        if (that.currentTime <\= this.duration) {
          that.currentTime += 800;
          this.callback(that.currentTime);
        } else {
          that.clearTimer();
        }
      }
    }, 300);
  }

  _// 暂停定时任务_
  pauseInterval() {
    this.paused \= true;
  }

  _// 恢复定时任务_
  resumeInterval() {
    this.paused \= false;
  }

 _ // 清除计时器_
  clearTimer() {
    clearInterval(this.timeout);
  }
}

@Entry
@Component
struct main {
  scroller: Scroller \= new Scroller();
  controller: RichEditorController \= new RichEditorController();
  timer?: Timer; _// 定时任务类_
  textLineHeight: number \= 20; _// 单行文字行高_
  lines: number \= 0; _// 组件显示范围内文本总行数_
  widthComp: number \= 0; _// 屏幕宽度_
  heightComp: number \= 0; _// 组件可视文本高度_
  start: number \= 0; _// 高亮文本开始索引_
  end: number \= 0; _// 高亮文本结束索引_
  @State currentTime: number \= 0; _// 定时任务执行进度_
  currentTabIndex: number \= 0; _// 当前展示高亮数据项索引_

  aboutToAppear(): void {
  _  // 初始化数据_
    Bean.init();
  _  // 获取屏幕宽度_
    this.widthComp \= this.getUIContext().px2vp(display.getDefaultDisplaySync().width);
  }

  onPageHide(): void {
    this.timer?.pauseInterval();
  }

  onPageShow(): void {
    if (this.timer?.paused) {
      this.timer.resumeInterval();
      return;
    }

    this.start \= 0;
    this.end \= 0;
    let line \= 0;
    let w: number \= 0;

    this.timer \= new Timer((time: number) \=\> {
      this.currentTime \= time;
   _   // 找到当前时间匹配上文本的开始时间和结束时间的数据项进行高亮_
      if ((Bean.textArray\[this.currentTabIndex\]) &&
        this.currentTime \>\= Bean.textArray\[this.currentTabIndex\].startTime &&
        this.currentTime < Bean.textArray\[this.currentTabIndex\].endTime) {
     _   // 取消上次高亮文本_
        this.controller.updateSpanStyle({
          start: this.start,
          end: this.end,
          textStyle:
          {
            fontColor: Color.Black
          }
        });

    _    // 更新高亮部分文本索引位置_
        this.start \= this.end;
        this.end += Bean.textArray\[this.currentTabIndex\].label.length;
        let width: number \= 0;

    _    // 更新高亮文本_
        this.controller.updateSpanStyle({
          start: this.start,
          end: this.end,
          textStyle:
          {
            fontColor: '#317AF7'
          }
        });
  _      // 获取高亮部分文本宽度_
        this.controller.getSpans({
          start: this.start,
          end: this.end
        }).forEach(item \=\> {
          width \= this.calcTextWidth((item as RichEditorTextSpanResult).value);
        });
        this.currentTabIndex += 1;

        w \= w \+ width;
        if (w \> this.widthComp) {
     _     // 多出的部分设为初始值_
          w \= w \- this.widthComp;
          line += 1;

      _    // 大于一半就开始滚动_
          if (line \> Math.floor(this.lines / 2)) {
         _   // 滚动单行文本行高距离_
            this.scroller.scrollBy(0, this.textLineHeight);
          }
        }
      }
    });
    this.timer.setTimer();
  }

  _// 计算文本宽度_
  calcTextWidth(text: string): number {
    let textWidth: number \= this.getUIContext().getMeasureUtils().measureText({
      textContent: text,
      fontSize: 16
    });
    return this.getUIContext().px2vp(textWidth);
  }

  build() {
    Column() {
      Row() {
        Text('文字高亮滚动：')
          .fontColor(Color.White)
          .fontSize(CommonConstants.fontSize16);
      }
      .width(CommonConstants.FULL\_WIDTH)
      .padding(CommonConstants.padding16)
      .backgroundColor('#317AF7');

      Scroll(this.scroller) {
        RichEditor({ controller: this.controller })
          .onReady(() \=\> {
            for (let i \= 0; i <\= Bean.textArray.length; i++) {
              this.controller.addTextSpan(Bean.textArray\[i\]?.label || '', {
                style: {
                  fontSize: CommonConstants.fontSize16,
                  lineHeight: this.textLineHeight,
                }
              });
            }
          })
          .padding(0)
          .hitTestBehavior(HitTestMode.None)
          .width(CommonConstants.FULL\_WIDTH);
      }
      .scrollBar(BarState.Off)
      .height(CommonConstants.HALF\_HEIGHT)
      .onAreaChange((\_oldValue: Area, newValue: Area) \=\> {
        this.heightComp \= Number(newValue.height);
        this.lines \= this.heightComp / this.textLineHeight;
      });

      Slider({
        value: $$this.currentTime,
        min: 0,
        max: this.timer?.duration,
      })
        .selectedColor('#317AF7')
        .enabled(false)
        .onChange((value: number, mode: SliderChangeMode) \=\> {
          if (mode \=== SliderChangeMode.Begin || mode \=== SliderChangeMode.Moving) {
          }
          if (mode \=== SliderChangeMode.End) {
            this.currentTime \= value;
            this.timer?.clearTimer();
          }
        });
    };
  }
}