本周是跟着尚硅谷的教程进行了一个实践项目的编写,相比于黑马的项目,我个人觉得尚硅谷的项目会比较的简单,结构比较清晰。更多使用@Builder和@Component,将组件封装在page页面中,好处是更容易寻找,而且不会有那么多的文件,坏处是代码会显得很冗长,而且代码的复用性会比较差。
本周的项目是一个单词打卡项目,包括了登录、打卡、答题等页面,是一个相对完整的项目,但这个项目更像一个纯前端项目,通过调用后端接口,而不是建表和操作数据库来进行数据的交互与存储。
欢迎页
这个页面非常的简单,就是一个背景图片加上一个转场动画。
背景设置
.backgroundImage($r('app.media.img_splash_bg'))
.backgroundImageSize({ width: '100%', height: '100%' })
转场动画
转场动画的详细介绍见第三周。
if(this.flag){
Image($r('app.media.ic_logo'))
.logoStyle()
.transition({type:TransitionType.Insert,opacity:0,translate:{x:-150}})
Text('快速单词记忆神器')
.titleStyle()
.transition({type:TransitionType.Insert,opacity:0,translate:{x:150}})
}
关键在于调用transition,并设置各种自定义属性。如type可以设置进场动画或者是出场动画。
跳转和动画启动逻辑
onPageShow(){
animateTo({duration:1000,onFinish:()=>{
setTimeout(()=>{
router.replaceUrl({url:'pages/Index'})
},200)
}},()=>{
this.flag=true;
})
}
当我们用transition设置好动画样式后,我们要用animateTo设置动画的运行时间 。
onfinish回调函数,用于设置动画完成后的逻辑。动画完成2s后,我们要跳转到到主页,所以用路由router的replaceUrl进行跳转。(用replaceUrl的原因是:欢迎页面一般只有在进入APP时要调用,后续不会再出现)
flag是控制动画开始与否的变量,当flag为true时,才会显示上面的Image和text,动画才会显示。
最外层的onPageShow是生命周期函数,当页面展示时会调用这个函数。
答题页
(细心的友友可能发现这个页面的停止测试的止不见了,还有一个奇怪的答案灌,这个我也不太清楚是为什么,在预览器的时候是正常的,但是用模拟器跑的时候就会这样,其他项目也没有问题。知道是为什么的友友麻烦评论区跟我说一下,谢谢大家!)
在这个页面中,我们可以看到底部又有熟悉的tabbar页面切换,这应该是每个手机软件的标配了。用于切换不同的主要页面。除此之外主要有三个部分,统计部分,单词部分,选项部分。
统计部分
由图可见,这四个部分的结构都是相同的,由一个icon加上text,中间一段空白,最后再有一个不同的组件。所以我们应该将这个结构单独封装起来,简化代码,提高复用性。但是我们最后有一个不同组件,要怎么封装哇,这时候我们就要引入一个新的装饰器@BuilderParam
@BuilderParam装饰的方法只能被自定义构建函数(@Builder装饰的方法)初始化。什么意思呢,就是我们可以用@Builder封装起来的内容,就可以传入到@BuilderParam装饰的方法中。
所以代码如下图所示:
@Component
export struct StatItem {
icon:Resource
name:string
@BuilderParam statComp:()=>void
fontColor:Color
build() {
Row({space:10}){
Image(this.icon)
.height(14)
.width(14)
Text(this.name)
.fontWeight(FontWeight.Medium)
.fontSize(14)
.fontColor(this.fontColor)
Blank()
this.statComp()
}
.width('100%')
.height(30)
}
}
以“进度”举例:
StatItem({
icon:$r('app.media.ic_progress'),
name:'进度',
fontColor:Color.Black
}){
Progress({value:this.answeredCount,total:this.totalCount})
.width(100)
}
在传入其他参数后,我们再传入一个对象,这个对象就和我们编写@builder一样,传入自定组件机器样式即可,如这个代码中就传入了一个进度条组件Progress。
同时我们每次每次完成一次答题,就会有一个记录我们答题情况的弹窗,这个弹窗的结构也和统计页面的样式相似,也可以复用这个结构。
个数
个数中的自定义组件是一个button组件,点击后会出现一个弹窗,是一个TextPickerDialog,是一个文本滑动弹窗。
用于设置本次答题组中包含多少个单词。
StatItem({
icon:$r('app.media.ic_count'),
name:'个数',
fontColor:Color.Black
}){
Button(this.totalCount.toString())
.width(100)
.height(25)
.backgroundColor('#EBEBEB')
.enabled(this.practiceStatus===PracticeStatus.Stopped)
.fontColor(Color.Black)
.onClick(()=>{
TextPickerDialog.show({
range:['5','10','15','20'],
value:this.totalCount.toString(),
onAccept:(result)=>{
this.totalCount=parseInt(result.value)
this.questions=getRandomQuestions(this.totalCount)
}
})
})
这里面有个enabled属性,标识什么时候这个组件可以被点击。practiceStatus是一个用于标识单词测试状态的变量,有停止、暂停、进行三个状态,只有状态为停止才可以进行修改。因为我们不可以测试测一半进行修改,这不符合逻辑。TextPickerDialog中的onAccept就是我们点击确定后的逻辑。getRandomQuestions是自定义的一个函数,用于从题库中抽取用户设置的题目数量的题。
export function getRandomQuestions(count: number) {
let length = questionData.length;
let indexes: number[] = [];
while (indexes.length < count) {
let index = Math.floor(Math.random() * length);
if (!indexes.includes(index)) {
indexes.push(index)
}
}
return indexes.map(index => questionData[index])
}
计时器
计时器中用到了一个新的组件TextTimer,是一个通过文本显示计时信息并控制其计时器状态的组件。
TextTimer({ controller: this.timerController })
.onTimer((utc,elapsedTime)=>{
this.timeUsed=elapsedTime
})
需要设置一个控制组件controller,需要提前进行声明:
timerController: TextTimerController = new TextTimerController();
声明后timerController将会有start、pause、stop三个属性,对应我们所定义的开始、暂停、停止。
onTimer事件时间文本发生变化时触发,elapsedTime:计时器经过的时间,单位为毫秒。
单词部分
这个部分就很简单了,就是两个text的组件,代码如下:
Column(){
Text(this.questions[this.currentIndex].word)
.wordStyle()
Text(this.questions[this.currentIndex].sentence)
.sentenceStyle()
}
选项部分
因为四个选项的样式逻辑全都相同,且储存在数组中,所以我们可以使用foreach循环来进行渲染
ForEach(this.questions[this.currentIndex].options,(option)=>{
OptionButton({
option: option,
answerStatus:this.answerStatus,
answer:this.questions[this.currentIndex].answer,
selectedOption:this.selectedOption
})
.enabled(this.answerStatus==AnswerStatus.Answering)
.onClick(()=>{
if(this.practiceStatus!==PracticeStatus.Running){
promptAction.showToast({message:'请先开始测试'})
return
}
this.selectedOption=option
this.answeredCount++
if(option===this.questions[this.currentIndex].answer){
this.rightCount++
}
this.answerStatus=AnswerStatus.Answered
if(this.currentIndex<this.questions.length-1){
setTimeout(()=>{
this.currentIndex++
this.answerStatus=AnswerStatus.Answering
},500)
}else{
this.stopPractice()
}
})
},option => this.questions[this.currentIndex].word + '-' + option)
OptionButton是封装的组件,也就是每个选项的样式和逻辑,如下:
@Component
struct OptionButton{
option:string
answer:string
@State optionStatus:OptionStatus=OptionStatus.Default
//注意!!声明顺序影响更新顺序,会导致出错
@Prop selectedOption:string
@Prop @Watch('onAnswerStatus') answerStatus:AnswerStatus
onAnswerStatus(){
if (this.option===this.answer) {
this.optionStatus=OptionStatus.Right
}else{
if(this.option===this.selectedOption){
this.optionStatus=OptionStatus.Wrong
}else{
this.optionStatus=OptionStatus.Default
}
}
}
getBgColor(){
switch (this.optionStatus) {
case OptionStatus.Right:
return '#1DBF7B'
case OptionStatus.Wrong:
return '#FA635F'
default:
return Color.White
}
}
build(){
Stack(){
Button(this.option)
.optionButtonStyle({
bg: this.getBgColor(),
font: this.optionStatus === OptionStatus.Default ? Color.Black : Color.White
})
if(this.optionStatus===OptionStatus.Right){
Image($r('app.media.ic_right'))
.width(22)
.height(22)
.offset({x:10})
}else if (this.optionStatus===OptionStatus.Wrong){
Image($r('app.media.ic_wrong'))
.width(22)
.height(22)
.offset({x:10})
}
}.alignContent(Alignment.Start)
}
}
首先我们要明确点击选项后我们需要触发什么逻辑操作:1、我们要判断所选项是否为正确选项;2、如果为正确选项,则该选项要变绿,不为正确选项则要变红,且正确选项要变绿;3、在选择后要跳转到下一题,若已经是最后一题则要跳出弹窗。
onAnswerStatus用于判断按键状态,判断所选是否为正确,getBgColor用于给按键添加背景颜色,正确或错误的背景色。answerStatus是用于监测用户是否已经点击了选项,监听到状态变化则触发onAnswerStatus。
if(this.currentIndex<this.questions.length-1){
setTimeout(()=>{
this.currentIndex++
this.answerStatus=AnswerStatus.Answering
},500)
}else{
this.stopPractice()
}
这段代码则是用于判断是否为本测试组的最后一题,如果是的话,则调用停止逻辑:
stopPractice(){
this.practiceStatus = PracticeStatus.Stopped
//停止计时器
this.timerController.pause()
//弹窗
this.dialogController.open()
}
弹窗内容就是前面讲到的完成答题后的弹窗。
Tabs页面切换
Tabs({ index: this.currentTabIndex }){
TabContent(){
PracticePage()
}.tabBar(this.barBuilder(0,'答题',$r('app.media.ic_practice'),$r('app.media.ic_practice_selected')))
TabContent(){
CirclePage()
}.tabBar(this.barBuilder(1,'圈子',$r('app.media.ic_circle'),$r('app.media.ic_circle_selected')))
TabContent(){
MinePage()
}.tabBar(this.barBuilder(2,'我的',$r('app.media.ic_mine'),$r('app.media.ic_mine_selected')))
}
用Tabs搭配TabContent和tabbar使用,TabContent中用于存放页面的UI界面 ,tabbar则是定义页面切换导航的样式,详见第五六周的文章。本代码中,把每个页面详细的样式封装成了单独的组件,然后用import引入后使用。
tabbar样式
@Builder barBuilder(index:number,title:string,icon:Resource,iconSelected:Resource){
Column(){
Image(this.currentTabIndex === index ? iconSelected : icon)
.width(25)
.height(25)
Text(title)
.tabTitleStyle(this.currentTabIndex === index ? Color.Black:'#959595')
}
}
这里自定义了tabbar 的样式,让其为图片加文字的组合。并且当tabbar被选中时会有不同其他tabbar的效果。