鸿蒙开发-阅读器正文页面实现

鸿蒙开发-阅读器正文页面实现

记录开发一个小说阅读应用的过程

实现点击书籍,读取该书籍的文件内容,然后显示该书籍正文内容,滑动进行翻页。


实现逻辑

  1. 在书架页面,获取书籍列表,为每一项添加点击事件,进行路由带参跳转,参数为书籍路径或书籍URL,跳转到正文页面。
  2. 进入正文页面后,设置阅读页面,文字大小、字体样式、行间距、段间距等。
  3. 根据书籍路径或URL,获取文件内容,根据章节拆分文件内容。
  4. 获取每章的内容,按字拆分,根据显示界面宽度、文字大小、是否换行等,得到每行显示的内容,用数组进行存储。
  5. 根据显示界面高度和每行文字的高度(文字大小×行间距)得到每页显示的行数,使用slice得到当前页、上一页和下一页的内容。
  6. 将当前页的内容绘制在显示区域内,上一页绘制在当前页的左边,下一页绘制在右边,使用offsetX变量控制滑动时的偏移量。

代码实现

  • 定义数据类
// 定义书籍类Book
class Book{
    bookUrl?:string
    bookName?:string
}
// 定义章节内容chapterContent
class chapterContent{
    Number?:number
    content?:string
    title?:string
}

  • 书架页面
struct bookshelf {
    @State bookList:Book[] = []
    
    aboutToAppear(){
    	this.getBookList()    
    }
    
    getBookList(){
        // 获取书籍列表,bookList在这里赋值
        // 例如
        this.bookList.push(
            new Book()
        )
    }
    
    build(){
        Column(){
             ForEach(this.bookList, (item: Book) => {
              ListItem() {
              	bookListView({ book: item }) // 自定义书籍列表组件
              }
              .onClick(() => {
                console.info('???打开书籍')
                router.pushUrl({
                  url: 'pages/view/reader/readerPage', // 进入阅读正文页面
                  params: {
                    // 传参,如果bookUrl为空,则使用默认值,目录'/data/storage/el2/base/haps/entry/files'为电脑文件复制到模拟机中的存储路径。
                    bookUrl: item.bookUrl ? item.bookUrl : '/data/storage/el2/base/haps/entry/files/xxxx.TXT', 
                  }
                })
              })

            })
        }
    }
}

  • 阅读正文页面
// API 12 13 编译通过
// 路由传参需要规范数据类型
class params {
  bookUrl: string = ''
}

struct readerPage {
    // 应该对bookUrl的值进行检验(是否存在,是否合法等等),但偷懒 
    @State bookUrl: string = (router.getParams() as params).bookUrl 
    // 或者这样
    // @State bookUrl:string = ''
    // @State Params: params = router.getParams() as params
    // aboutToAppear(){
    // 	this.bookUrl = this.Params.bookUrl
	// } 
    //*********************************//
    // 以下看心情设定
      @State currentChapterContent: number = 0 // 当前章节编号
      @State lineSpacing: number = 1.8 // 行间距(行高相对于字体大小的倍数)
      @State currentFontSize: number = 20 // 当前字体大小
      @State paragraphSpacing: number = -2 // 段落间距
    // ……
    
    build(){
    	Column(){
            // 自定义组件绘制正文
           TxtContentDisplayModel({
              bookUrl: this.bookUrl,
              currentChapterContent: this.currentChapterContent,
              lineSpacing: this.lineSpacing,
              currentFontSize: this.currentFontSize,
              paragraphSpacing: this.paragraphSpacing,
        	})
        }
    }
    
   
}

  • 正文绘制
  1. 首先根据书籍路径或URL读取文件内容,并将读取到的内容按照章节拆分

遍历文件内容的每一行(遇到换行为止),使用正则表达式匹配章节名称,匹配成功说明该行内容是章节名,则创建新的章节内容对象(chapterContent),设置该对象的章节名和章节编号,将该对象放入章节列表数组中;匹配失败说明是章节内容,则将该行与章节列表数组中最后一个成员的content拼接作为章节内容。返回章节列表数组。

  static async readFile(readFileUrl: string) {
    let chapterNumber = 0
    // const chapters: chaptersItem[] = [];
    const chapters: chapterContent[] = [];
    console.info('???readFileUrl:' + readFileUrl)
    //const regex = /===第(.*?)章 (.*?)===/g;
    // 使用正则表达式匹配章节名称
    const regex =
      /^[=|<]{0,4}((?:序章|楔子|番外|第\s{0,4})([\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\s{0,4})(?:章|回(?!合)|话(?!说)|节(?!课)|卷|篇(?!张)))(.{0,30})/g;
    await fs.readLines(readFileUrl, options).then((readerIterator: fs.ReaderIterator) => {
      for (let it = readerIterator.next();!it.done; it = readerIterator.next()) {
        const match = regex.exec(it.value);
        if (match) {
          const chapterTitleNumber = match[1]; // 内部章节
          const chapterTitle = match[3];
          chapterNumber++
          chapters.push(new chapterContent(chapterNumber, chapterTitle, chapterTitleNumber, ''))
        } else {
          if (chapters.length > 0) {
            chapters[chapters.length - 1].content += it.value
          }
        }
      }
    }).catch((err: BusinessError) => {
      console.error("???readLines failed with error message: " + err.message + ", error code: " + err.code);
    });

    return chapters
  }
  1. 根据当前章节编号(初始编号为0)获取章节内容,按字分割,得到每一行显示的内容。

遍历章节内容(类型为string)(通过索引得到每个字),将遍历到的内容和\n匹配,匹配失败说明不是换行,则检查是否小于显示区域,小于则将遍历到的内容拼接到当前行中(使用当前行 currentLine 存储每行显示的内容),大于则将当前行用变量chaptersItemLines存储起来,并把遍历到的内容赋值给当前行,检查结束后索引加1;匹配成功说明是换行,则将当前行存储起来,索引加2,并把当前行置空。得到当前章节内容CurrentChaptersContent

 // 通过章节编号获取章节内容
  GetCurrentChapterByNumber(Number: number): string {
    return this.chapters[Number].content
  }

  // 章节内容按字分割
  splitText(context: CanvasRenderingContext2D) {

    // let content: string = this.textContent
    let content: string = this.GetCurrentChapterByNumber(this.currentChapterContent)
    //
    let chaptersItemLines: string[] = []
    let currentLine: string = ''
    let pattern = /\n/
    let i = 0
    // console.info(`???当前章节内容长度为:` + content[i])
    while (i < content.length) {
      let temp = content[i] + content[i+1]
      // console.info(`??? ${i} == ${temp}`)
      if (!pattern.test(temp)) {
        // 检查是否小于显示区域
        if (context.measureText(currentLine + ' ' + content[i]).width <
        Number((this.screenWidth - 10).toFixed(0))) {
          currentLine += content[i];
        } else {
          chaptersItemLines.push(currentLine);
          currentLine = content[i];
        }
        i++
      } else {
        // console.info(`??? ||| ${temp == '\r\n'}`)
        chaptersItemLines.push(currentLine);
        i = i + 2 // 换行存在 \r\n
        currentLine = '';
      }
      // console.info(`??? push ${content[i]}`)

    }
    if (pattern.test(chaptersItemLines[chaptersItemLines.length-1])) {
      chaptersItemLines.splice(chaptersItemLines.length - 1, 1)
    }
    this.CurrentChaptersContent = chaptersItemLines
    this.drawPage();
  }

  1. 绘制文本

计算显示区域内能显示多少行(每页行数=显示高度/每行高度)(每行高度=文字大小*行间距),使用slice函数处理当前章节内容CurrentChaptersContent,得到每页显示的内容,在预定区域绘制文本。上一页和下一页与当前页距离一个显示区域的宽度,使用offsetX控制滑动显示。

  // 绘制文本
  drawPage() {

    this.context.font = `${this.currentFontSize}vp`
    // 计算屏幕上可以显示的行数 = 屏幕高度 / (字体大小 * 行间距)   向下取整
    // console.info(`??? ${Math.floor((this.screenHeight - 150) / (this.currentFontSize * this.lineSpacing))}`)
    // this.linesPerPage = Math.floor((this.screenHeight - 150) / (this.currentFontSize * this.lineSpacing))
    // 感觉使用显示区域高度会好一些
    console.info(`??? ${Math.floor(this.context.height / (this.currentFontSize * this.lineSpacing))}`)
    this.linesPerPage = Math.floor(this.context.height / (this.currentFontSize * this.lineSpacing))
    if (this.context) {
      // 初始化画布
      this.context.clearRect(0, 0, this.screenWidth, this.screenHeight);
      this.context.font = `${this.currentFontSize}vp`;
      this.context.fillStyle = '#000000';

        // 计算当前页行数和内容
      const start = this.currentPage * this.linesPerPage;
      const end = start + this.linesPerPage
      const currentPageLines = this.CurrentChaptersContent.slice(start, end);

      // console.info(`??? start: ${start}  end: ${end}`)
      // currentPageLines.forEach((line, index) => {
      //   console.info(`???第${index}行:${line}`)
      // })

        // 绘制文本
      currentPageLines.forEach((line, index) => {
        this.context.fillText(line, 10 + this.offsetX, (index + 1) * this.currentFontSize * this.lineSpacing);
      })

      const preStart = start - this.linesPerPage
      const nextEnd = end + this.linesPerPage

      // 上一页内容
      const prePageLines = this.CurrentChaptersContent.slice(preStart, start - 1);
      prePageLines.forEach((line, index) => {
        this.context.fillText(line, 10 - this.screenWidth + this.offsetX,
          (index + 1) * this.currentFontSize * this.lineSpacing);
      })
      // 下一页内容
      const nextPageLines = this.CurrentChaptersContent.slice(end, nextEnd);
      nextPageLines.forEach((line, index) => {
        this.context.fillText(line, 10 + this.screenWidth + this.offsetX,
          (index + 1) * this.currentFontSize * this.lineSpacing);
      })
    }

  }

  1. 使用Canvas组件绘制内容,为组件设置滑动事件
    Canvas(this.context)
    .width('100%')
    .height('100%')
    .backgroundColor('#FEFEFE')
    .onReady(() => {
      //绘制填充类文本
      // this.drawPage()
      this.context.font = `${this.currentFontSize}vp sans-serif`;
      this.splitText(this.context)
    })
    .gesture(
      PanGesture({ direction: PanDirection.Left | PanDirection.Right })
        .onActionUpdate((Event) => {
          // console.info(`???滑动中`)
          // console.info(`???${JSON.stringify(Event)}`)
          this.offsetX = Event.offsetX
          this.drawPage()
        })
        .onActionEnd((Event) => {
          // console.info(`???滑动结束`)
          // console.info(`???${JSON.stringify(Event)}`)
          console.info(`???currentPage: ${this.currentPage}`)
          if (Event.offsetX > 100 && this.currentPage == 0) {
            if (this.currentChapterContent > 0) {
              this.currentPage = 0
              this.currentChapterContent--
              this.splitText(this.context)
            } else {
              showMessage('没有上一页了') // 自定义组件,用于弹出提示,可以用日志输出代替
            }
          } else if (Event.offsetX > 100 && this.currentPage > 0) {
            this.currentPage--
            console.info('??? 上一页')
          } else if (Event.offsetX < -100 && this.currentPage < this.GetTotalPages()) {
            this.currentPage++
            console.info('??? 下一页')
          } else if (Event.offsetX < -100 && this.currentPage == this.GetTotalPages()) {
            if (this.currentChapterContent < this.chapters.length) {
              this.currentPage = 0
              this.currentChapterContent++
              this.splitText(this.context)
            } else {
              showMessage('没有下一页了')
            }
          }

          this.offsetX = 0
          this.drawPage()

        })
    )


运行结果

可以添加动画,避免翻页过程过于生硬,但偷懒


需要注意的

  • 由于预览器不支持读取文件,需要使用模拟器。

将电脑文件拖到模拟机上,会复制文件到目录/data/storage/el2/base/haps/entry/files/下。

  • 日志输出标记

由于运行模拟机时会输出大量日志,不方便查看自己写的的输出,可以使用console.info('??? xxxxx') ,用???对日志输出内容进行标记,方便查看。

posted @ 2025-01-03 10:26  末雨摸鱼  阅读(296)  评论(0)    收藏  举报