鸿蒙开发-阅读器正文页面实现
记录开发一个小说阅读应用的过程
实现点击书籍,读取该书籍的文件内容,然后显示该书籍正文内容,滑动进行翻页。
实现逻辑
- 在书架页面,获取书籍列表,为每一项添加点击事件,进行路由带参跳转,参数为书籍路径或书籍URL,跳转到正文页面。
- 进入正文页面后,设置阅读页面,文字大小、字体样式、行间距、段间距等。
- 根据书籍路径或URL,获取文件内容,根据章节拆分文件内容。
- 获取每章的内容,按字拆分,根据显示界面宽度、文字大小、是否换行等,得到每行显示的内容,用数组进行存储。
- 根据显示界面高度和每行文字的高度(文字大小×行间距)得到每页显示的行数,使用
slice
得到当前页、上一页和下一页的内容。 - 将当前页的内容绘制在显示区域内,上一页绘制在当前页的左边,下一页绘制在右边,使用
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,
})
}
}
}
- 正文绘制
- 首先根据书籍路径或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
}
- 根据当前章节编号(初始编号为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();
}
- 绘制文本
计算显示区域内能显示多少行(每页行数=显示高度/每行高度)(每行高度=文字大小*行间距),使用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);
})
}
}
- 使用
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')
,用???
对日志输出内容进行标记,方便查看。